Практические примеры
Ссылки на репозитории с примерами:
Containy – реализация контейнерной утилиты на языке Golang
Namespaces example – лёгкий пример работы пространств имён на C++
Всё это будет подробно разобрано в статье. Но не пугайтесь, утилита на Golang необязательна для понимания всего того, о чём пойдёт речь. Это бонус для любителей гоферов.
Также, в каждом из репозиториев дано отдельно описание, но для начала лучше прочитать статью :)
Глоссарий
Программа - текстовый файл, который содержит в себе код на каком либо из языков программирования;
Процесс – абстракция операционной системы, позволяющая следить за ходом выполнения программы;
Ядро – программа, лежащая в основе операционной системы, написанная на системном языке(например на C);
Операционная система – ядро и стандартные пользовательские приложения;
Системный вызов – API операционной системы, которым пользуются пользовательские процессы для доступа к системным ресурсам(выделение памяти, доступ к сетевой карте, обработка нажатия клавиши на клавиатуре и т.д.);
Хостовая система - операционная система, на базе которой разворачиваются контейнер или виртуальная машина.
Содержание
Для кого эта статья?
Для кого эта статья? В мире, где больше всего ценится не столько фундаментальное понимание инструмента и обладание контекстом его использования, сколько знание всех его опций и поведения в различных сценариях, которые могут встретиться на пути разработки – живой и неподдельный интерес, подобно распаковке подарка под ёлкой в детстве, как минимум забывается, а как максимум пропадает. Поэтому, это статья для тех, кому не всё равно, для тех, кто хочет вдохнуть жизнь в и без того безжизненные терминальные команды или кнопки в UI интерфейсе.

Далее будут разобраны возможности ядра Linux, с помощью которых реализуются всевозможные механизмы контейнеризации. Для понимания сказанного ниже, лучше обладать самыми базовыми знаниям Linux, но и это необязательно, мы постараемся дотошно(в хорошем смысле этого слова) пройти по всем ступеням понимания данной темы. Все аспекты Linux, которые нам понадобятся для понимания будут разбираться по мере надобности и продвижения, будем идти от общего к частному, от простого к сложному. Но надо быть готовым к тому, что с первого раза не всё будет ясно на все 100. Ведь многие не совсем тривиальные вещи невозможно объяснить без "сглаживания углов" на первых парах. Поэтому совету вдумчиво прочитать статью и изучить примеры как минимум два раза, сделав перерыв между прочтениями, лучше всего в виде сна :)
Linux, что ты такое?
Не только механизмы контейнеризации, но и многие другие известные инструменты полагаются исключительно на возможности ядра Linux. В слове ядро нет абсолютно ничего страшного и вот почему.

Любая программа, которую мы запускаем на Linux, Window или MacOS запускается в неком окружении, которое мы и называем операционной системой, а более точно – ядром операционной системы. Ведь под операционной системой подразумеваются и стандартные приложения, которые знакомы каждому с детства. А вот само ядро запускается без какого либо окружения. Есть просто процессор, выполняющий определённый набор команд(сложение, вычитание, перенос из одного регистра в другой и т.д.), и BIOS, который загружает ядро операционной системы в оперативную память после нажатия кнопки питания на корпусе ПК. Фундаментальной задачей ядра является управление процессами и создания для них “комфортных условий”, в которых им “удобно существовать”.
Получается, ядро это обычная программа(в случае Linux на языке C), основная задача которой заключается в управлении пользовательскими процессами и которая запускается в более сырой среде, посредством загрузчика, написанного на языке ассемблера. И порождает среду всем знакомую посредством своего запуска. Вообщем, в одном предложении, ядро просто берёт ассемблерные коды программ и исполняет их по очереди на процессоре в соответствии с неким алгоритмом, это и является его фундаментальной задачей.
Также, ядро предоставляет API(системные вызовы) для пользовательских процессов, посредством которого они могут безопасно управлять системными ресурсами без риска “что-то сломать”. Знакомы многие глобальные инциденты, когда неправильно написанные программы, находящиеся в пространстве ядра “что-то ломали”. Поэтому наши обычные программы находятся в пользовательском пространстве и все привилегированные операции делегируют ядру, в этом и заключается суть системных вызовов. Да, получается медленнее, но зато безопаснее! Именно этим API Linux и пользуется Docker для реализации всего своего мистического функционала и о возможностях именного этого API мы поговорим ниже.
Подождите, ядро Linux.. API Linux… А как же тогда Docker работает на Windows? Да вот так!
Chroot: у истоков контейнеризации
Немного теории
В любом популярном Linux дистрибутиве корень дерева каталогов файловой системы обозначается как слеш – /
. И любой процесс, запущенный в системе, может “достучаться” до любого файла на диске(разумеется, если он имеет соответствующие права).

Chroot же меняет корень дерева каталогов процесса. Иными словами, он заставляет думать процесс, что его корневой каталог не /
, а любой другой, который мы указали при запуске chroot
. Например /usr
. Таким образом, процесс не сможет "достучаться" выше, чем /usr
до директорий /bin
, /lib
, /dev
, /etc
. Его область видимости файлов в файловой системе будет ограничена директорией /usr
.

Много практики!
Для начала посмотрим структуру команды:
man chroot
Сначала нужно указать новый корень, а потом программу, которая должна запуститься думая, что её корень дерева каталогов находится в указанной директории.
CHROOT(8) User Commands CHROOT(8)
NAME
chroot - run command or interactive shell with special root directory
SYNOPSIS
chroot [OPTION] NEWROOT [COMMAND [ARG]...]
Создадим новую директорию и попробуем запустить процесс bash
, который будет думать, что его корень находится не в /
, а в /hello-habr
. Для этого создадим каталог /hello-habr
и попробуем запустить chroot
:
mkdir /hello-habr
chroot /hello-habr bash
В итоге мы получаем ошибку… chroot: failed to run command ‘bash’: No such file or directory
.
Дело в том, что когда мы запускаем программу через терминал, указывая её название, а не полный путь до исполняемого файла, терминал(или командная оболочка, в данном контексте различия не так уж и важны) ищет исполняемый файл с указанным названием в стандартных каталогах(пути к ним лежат в переменной окружения $PATH
, окружение вашей командной оболочки можно посмотреть введя в терминале команду env
) .
Вот самые частые каталоги, где хранятся исполняемые файлы программ: /bin
, /sbin
, /usr/bin
, /usr/sbin
и /usr/local/bin
.
Разумеется, ищет он их от корня. А так как в нашем новом корне /hello-habr
нет абсолютно ничего, то и исполняемого файла bash
терминал не находит.
Давайте же добавим его! А заодно и ls
:
mkdir /hello-habr/bin
cp /bin/bash /bin/ls /hello-habr/bin/
Пробуем запустить chroot
снова:
chroot /hello-habr bash
Но мы опять получаем ошибку… chroot: failed to run command ‘bash’: No such file or directory
.
Проблема в том, что исполняемые файлы bash
и ls
полагаются на динамические библиотеки, которых нет в нашем новом окружении. Давайте же посмотрим от каких библиотек зависят наши исполняемые файлы с помощью утилиты ldd
и добавим их по стандартным путям в наш новый корень /hello-habr
:

Теперь пробуем запустить:

Ура, всё работает! Теперь наш процесс bash
изолирован в контексте файловой системы и принимает за корень тот каталог, что в хостовой системе корнем не является.
Получается, chroot
сначала меняет корень, а затем ищет и запускает программу уже внутри нового окружения. Если исполняемый файл или его зависимости отсутствуют в новом корне, запуск не удаётся.
Namespaces: основа любого контейнера
Linux namespaces в примерах
Отбросив несостоятельные аналогии, коими кишит интернет, постараемся сразу сказать максимально точно и просто. Пространства имён это точки доступа(дескрипторы) процесса к ресурсам операционной системы. Каждый процесс по умолчанию создается в дефолтных пространствах имён, но может менять их в ходе своего исполнения или запускаться сразу в новых. Для полного понимания вышесказанного, напишем простой пример на C++. Тут я разберу лишь скелет кода и дам пояснения, полную версию с описанием вы можете найти на github.

Итак, представим, что процессы в нашей системе имеют два фундаментальных ресурса: массив и строка. Они в состоянии управлять(менять/удалять) этими ресурсами, а также делать fork
. То есть порождать новый процесс, тем самым становясь родителем этого процесса, образуя дерево(иерархию) процессов(в Linux дерево процессов можно посмотреть с помощью утилиты pstree
).
Процесс в нашем примере описывается следующей структурой:
/**
* Структура, представляющая процесс
*/
struct process {
int process_id; /* Идентификатор процесса */
string process_name; /* Имя процесса */
child_proc *children; /* Список дочерних процессов(детей) */
process_namespaces *namespaces; /* Пространства имён процесса */
void unshare(NAMESPACES ns);
void setNewString(string str);
void setNewArray(vector<int> arr);
process *forkProcess(string new_process_name);
};
Что мы тут видим? У процесса есть поля и методы. В полях содержится имя процесса, id процесса, указатель на список детей процесса и самое главное, указатель на пространство имён процесса.
Структура, представляющая пространства имён, указатель на которую есть в структуре процесса, содержит два указателя на каждое из пространств имён. В нашем примере пространств имён всего лишь два: пространство строк и пространство массивов, они олицетворяют возможные ресурсы процессов.
struct process_namespaces {
array_ns *ans; /* Пространство массивов */
string_ns *sns; /* Пространство строк */
};
А что же из себя представляют эти ресурсы? Просто массив и строка с их длинами, а также конструкторы по умолчанию, о которых мы поговорим позже.
struct array_ns {
int arr_len; /* Длина массива */
vector<int> array; /* Указатель на массив */
/* Конструктор по умолчанию */
array_ns();
};
struct string_ns {
int str_len; /* Длина строки */
string str; /* Обычная строчка */
/* Конструктор по умолчанию */
string_ns();
};
Также нужно создать процесс init, как и в любой Linux системе, который является родителем всех процессов и находится в корне дерева процессов.
/**
* Релизация функции для создания init процесса
*/
process *CreateInitProcess(string init_process_name) {
/* Выделение памяти под init процесс */
process *init_proc = new process;
/* Устанавливаем ID процесса и его имя */
init_proc->process_id = process_count;
init_proc->process_name = init_process_name;
/* Выделение памяти под пространства имён init процесса */
process_namespaces *init_ns = new process_namespaces;
/* Выделение памяти под базовые пространства имён */
array_ns* init_ans = new array_ns;
string_ns* init_sns = new string_ns;
/* Устанавливаем только что созданные пространства имён */
init_ns->ans = init_ans;
init_ns->sns = init_sns;
/* Инициализация пространства имён init процесса */
init_proc->namespaces = init_ns;
/* Инкрементируем счётчик процессов */
process_count += 1;
/* У процесса init пока что нет детей */
init_proc->children = nullptr;
return init_proc;
}
Другие же процессы(обычные/пользовательские) создаются с помощью метода forkProcess
. Этот метод почти идентичен функции CreateInitProcess
, только там мы:
Работаем со связным список дочерних процессов(конкретно для нашего примера это не так уж и важно. Мы делаем это, чтобы можно было визуализировать дерево процессов);
Не создаём новых пространств имён, а просто наследуем их от родительского процесса(в Linux так и работает).
/**
* Релизация функции forkProcess для process
*/
process *process::forkProcess(string new_process_name) {
/* Выделение памяти под новый процесс */
process *new_proc = new process;
/* Устанавливаем ID процесса и его имя */
new_proc->process_id = process_count;
new_proc->process_name = new_process_name;
/* Установка родительских пространств имён */
new_proc->namespaces = this->namespaces;
/* Новый процесс становится ребёнком того процесса,
через который был вызван метод forkProcess */
child_proc* new_child = new child_proc;
new_child->proc = new_proc;
new_child->next = nullptr;
/* Если детей нет, то просто добавляем первого */
if (this->children == nullptr) {
this->children = new_child;
/* Иначе идём по указателям next до последнего
ребёнка и добавляем в конец списка */
} else {
child_proc* curr_child = this->children;
while (curr_child->next) {
curr_child = curr_child->next;
}
curr_child->next = new_child;
}
/* Инкрементируем счётчик процессов */
process_count += 1;
/* У нового процесса пока что нет детей */
new_proc->children = nullptr;
return new_proc;
}
Мы почти у цели! Создадим в функции main() несколько процессов и посмотрим на их дерево:
/** Создание init_proc, процесса с Process ID(PID) = 1 */
process *init_proc = CreateInitProcess("init_proc");
/** Создание дочерних для init_proc процессов */
process *floppa_cat_proc = init_proc->forkProcess("floppa_cat_proc");
process *ploob_cat_proc = init_proc->forkProcess("ploob_cat_proc");
/** Создание дочерних для floppa_cat_proc процессов */
process *komaru_cat_proc = floppa_cat_proc->forkProcess("komaru_cat_proc");
process *zigmund_cat_proc = floppa_cat_proc->forkProcess("zigmund_cat_proc");
/** Создание дочерних для zigmund_cat_proc процессов */
process *barsik_cat_proc = zigmund_cat_proc->forkProcess("barsik_cat_proc");
process *murzik_cat_proc = zigmund_cat_proc->forkProcess("murzik_cat_proc");
/** Отображаем дерево процессов */
DrawProcessesTree(init_proc);
Вот так будет выглядеть дерево только что созданных процессов:
.
├── init_proc (PID: 1)
├── floppa_cat_proc (PID: 2)
├── komaru_cat_proc (PID: 4)
├── zigmund_cat_proc (PID: 5)
├── barsik_cat_proc (PID: 6)
├── murzik_cat_proc (PID: 7)
├── ploob_cat_proc (PID: 3)
Теперь посмотрим на ресурсы процессов:
/** Смотрим на ресурсы процесса init_proc, komaru_cat_proc и murzik_cat_proc */
DumpProccessInfo(init_proc);
DumpProccessInfo(komaru_cat_proc);
DumpProccessInfo(murzik_cat_proc);
Вот так выглядит, к примеру, выглядит init_proc:
[init_proc] :: ------------------------------------------
PID: 1
Namespaces:
Array NS:
Array Len: 5
Array: [1 2 3 4 5 ]
String NS:
String Len: 12
String: Hello, Habr!
Строка “Hello, Habr!” и массив [1 2 3 4 5 ] – это значения по умолчанию, которые задаются в конструкторе по умолчанию при создании нового namespace(можете посмотреть на это подробнее в исходном коде). Так как при создании init_proc был создан новый namespace, а все остальные процессы наследовали его, то вывод для каждого из процессов по части массива и строки будет одинаковый. Вы можете проверить это, клонировав репозиторий и запустив make run
или изучив README.md.
Теперь процесс komaru_cat_proc хочет создать новое пространство строк и поменять строчку, а также поменять массив, не меняя пространство массивов:
/* Создаём новое пространство строк для komaru_cat_proc */
komaru_cat_proc->unshare(NAMESPACES::STRING_NS);
/* komaru_cat_proc устанавливает новую строку */
komaru_cat_proc->setNewString("I am from a new Namesapce, unshared by komaru_cat_proc");
/* komaru_cat_proc устанавливает новый массив */
komaru_cat_proc->setNewArray({123, 345, 789, 101112, 131415});
Теперь взглянем на init_proc, komaru_cat_proc и murzik_cat_proc:
[init_proc] :: ------------------------------------------
PID: 1
Namespaces:
Array NS:
Array Len: 5
Array: [123 345 789 101112 131415 ]
String NS:
String Len: 12
String: Hello, Habr!
[komaru_cat_proc] :: ------------------------------------------
PID: 4
Namespaces:
Array NS:
Array Len: 5
Array: [123 345 789 101112 131415 ]
String NS:
String Len: 45
String: I am from a new Namesapce, unshared by komaru_cat_proc
[murzik_cat_proc] :: ------------------------------------------
PID: 7
Namespaces:
Array NS:
Array Len: 5
Array: [123 345 789 101112 131415 ]
String NS:
String Len: 12
String: Hello, Habr!
Так как komaru_cat_proc изменил массив, не создавая нового пространства массивов, то фактом его изменения он повлиял на все процессы в системе, теперь у всех новый массив. А вот строка новая только у komaru_cat_proс, так как этот процесс изменил её уже в новом пространстве строк.
Итак, процесс komaru_cat_proc теперь изолирован, он может менять строку, не затрагивая при этом строки других процессов.
Дабы не захламлять статью примерами, другие сценарии можно найти в репозитории, ссылка на который была в начале статьи.
Linux namespaces по-взрослому
Linux namespaces – это абстракция над ресурсами в операционной системе. Согласен, может быть не совсем понятно. Что вообще есть абстракция в контексте ядра операционной системы? Давайте взглянем на представление процесса в ядре linux, который также является абстракцией над распределением физических ресурсов системы. Определение структуры процесса можно найти тут. Да, это просто структура, пусть и на сотни строк, но с обычными полями, которые и представляют все атрибуты процесса. Да, сложно, но не сверхъестественно. На четыре сотни строк ниже в этой же структуре task_struct
спряталось наиболее важное для нас поле – nsproxy, оно то и олицетворяет пространства имён процесса. А вот и сама структура nsproxy.

И тут самое время вспомнить, что tusk_struct это process в нашем примере, а nsproxy это process_namespaces. Получается, что Namespaces есть некая прослойка между желанием процесса получить доступ к ресурсам и самими ресурсами.
У нас эта прослойка реализуется в структуре процесса этим полем:
process_namespaces *namespaces;
А в ядре Linux вот этим:
struct nsproxy *nsproxy
Да, так как мы писали на C++, а не на C, слово struct вначале писать не обязательно.
Работа с namespaces: unshare
Наверное многие слышали о том, что в Linux "Всё есть файл", но до конца не понимали что это значит.
Начнём с того, что у нас есть жёсткий диск, для нас это просто массив байт. И не важно, крутится ли там под корпусом диск под считывающей головкой(HDD) или изменяется уровень заряда транзистора c плавающим затвором(SSD). Для нас важно лишь то, что это чудо техники умеет хранить информацию. Также, у диска должна быть конкретная разметка, то есть некая системная предзаписанная информация. Подобно тому, как в этой статье есть введение, содержание, основная часть с разделами, заключение и список литературы. Без этой структуры глазу было бы не за что зацепиться и статья превратилась бы не в структурированный набор информации для изучения, а в кашу. Точно также с диском и ПК, который этот диск умеет считывать и отображать нам файлы, которые на нём хранятся.

Так вот, обычные файлы(картинка, стих, программа на Python) хранятся физически на жёстком диске. Но в Linux многие файлы в файловой системе хранятся не на жёстком диске, а в оперативной памяти и существуют постольку, поскольку ПК включён. Такие файлы могут олицетворять как физические устройства, такие как мышь, клавиатура или сетевая карта(находятся они в директории /dev
), так и логические ресурсы системы, например содержать информацию о процессах(находятся в директории /proc
).
Прелесть такого подхода в контексте, например, физических устройств, в том, что со всеми устройствами из кода мы общаемся всего через два системных вызова read() и write()(как и с обычным файлом, который хранится на жёстком диске). Также и с любыми другими логическими и физическими сущностями, скрывающимся под маской файловой системы. С помощью данного подхода мы получаем удобный и мощный интерфейс взаимодействия с любыми ресурсами, это и есть фундаментальная абстракция UNIX-подобных операционных систем - файловая система!
Но долой лирику, нас будет интересовать каталог /proc
. Или же, в народе, procfs(файловая система процессов).
Вот так выглядит содержимое этого каталога:

Каждая директория имеет номер, который означает PID(Process ID) конкретного процесса. В каждой из этих директорий содержится подробная информация о процессе c соответствующем PID. Все эти файлы не хранятся на диске. Ядро просто создаёт для нас иллюзию того, что это файлы. На самом деле, это просто ресурсы операционной системы(процессы и их атрибуты в данном случае), которые отображаются в файловую систему, давая нам тем самым удобный интерфейс для взаимодействия с информацией о них. Это понимание пригодится нам при разборе cgroups позже.
Разумеется, в procfs содержится информация о том, к каким пространствам имён принадлежит процесс. Давайте взглянем что содержится в папке процесса с PID 117 и выведем в терминал содержимое папки ns(сокращение от namespaces).

Все эти файлы, что олицетворяют каждое из пространств имён не хранятся на диске. Это как бы указатели на ресурсы ядра, а числа в скобках - это ID конкретных Namespaces.

В нашей реализации на C++ был метод unshare
, с помощью которого процесс мог изменить свой Namespaces. В Linux есть одноимённая утилита, давайте посмотрим на её описание:
man unshare
UNSHARE(1) User Commands UNSHARE(1)
NAME
unshare - run program in new namespaces
SYNOPSIS
unshare [options] [program [arguments]]
Итак, нужно указать сначала опции(какие пространства мы хотим создать), а потом саму программу, которую мы хотим запустить в этих новых пространствах.
Для начала просто откроем два терминала(два разных процесса) и посмотрим на их пространства имён:

Как мы можем видеть, они идентичны, так как процессы наследуют базовые пространства имён по умолчанию.
Моя оболочка называется fish и её PID(так как оболочка тоже процесс, а у каждого процесса есть PID) лежит в переменной окружения $fish_pid. У bash его PID будет лежать в переменной $$.
Поэкспериментируем с UTS Namespaces, это пространство имён отвечает за hostname и domainname машины.

Как мы можем видеть, до ввода unshare
в левом терминале, меняя там же hostname, это изменение затрагивало совершенно другой процесс(правый терминал), но после вызова unshare c флагом --uts у нас запустился новый процесс fish, который находится уже в другом UTS Namespace. И теперь изменения hostname в левом терминале никак не влияют на правый и наоборот, прямо как в нашем примере на C++!
Обратите внимание, что ID всех пространств одинаковые у этих двух процессов, кроме UTS. И да, UTS расшифровывается как UNIX Timeshare System. Почему? Да кто его знает...

В целях не захламлять статью практически одинаковыми примерами, вы можете поэкспериментировать сами или посмотреть лекции легендарного Майкла Керриска, ссылки на которые будут в самом конце в списке литературы.
Важно понять основную концепцию "прослойки" между желанием процесса получить ресурс и самим ресурсом. В виде этой прослойки и выступают пространства имён. А самих примеров можно привести много:
Mount Namespace - работает почти так же, как и
chroot
, ограничивает область видимости процесса в контексте файловой системыPID Namespace - процесс, созданный в новом PID Namespace не будет видеть дерево процессов хостовой системы, он будет видеть только те, что породил он сам. То есть будет думать, что он является процессом init с PID равным 1.
Network Namespace - разные процессы могут видеть разные сетевые интерфейсы. Например, у нас в ПК есть как сетевая карта для беспроводной передачи данный, так и Ethernet разъём. Один процесс может видеть только Ethernet интерфейс вообще не подозревая о том, что в системе, где он находится, возможна беспроводная передача данных, а другой процесс наоборот и т.д.
Cgroups: новые возможности
Контрольные группы: теория
Если с помощью Namespaces можно изолировать процесс относительно логических ресурсов, то Cgroups(Control groups) позволяют делать тоже самое относительно физических ресурсов системы. Не будем углубляться в историю и разбирать устаревшие Cgroups V1, а сразу сосредоточимся на Cgroups V2(далее просто Cgroups), которые по умолчанию используются в большинстве популярных Linux дистрибутивах на данный момент. Для тех, кто хочет подробнее разобраться в исторических аспектах этого инструмента, в списке литературы будет ссылка на прекрасную статью на Хабре на эту тему.
Итак, как мы уже поняли, многие вещи в Linux имеют удобный интерфейс в виде файловой системы, Cgroups не исключение. Всё, что нужно сделать для ограничения ресурсов для группы процессов - это создать одну папку и записать значения в несколько файлов. Но прежде чем мы этим займёмся, давайте поймём как это работает в общих чертах и на какие физические ресурсы можно накладывать ограничения. Под группой процессов далее подразумевается один или более процессов.

Мы можем наложить ограничения на:
Процессор(CPU) - например сказать, что данная группа процессов может исполнятся только на определённом ядре(или ядрах) процессора или что группа процессов может использовать только часть процессорного времени;
Память - мы можем установить лимит памяти для группы процессов и как только лимит каким-либо из процессов в группе будет превышен, этот процесс будет убит специальным механизмом ядра Linux OOM Killer. Также мы можем запретить выгружать страницы памяти на жёсткий диск в swap раздел(раздел подкачки);
Потоки - можно ограничить количество процессов/потоков(ведь поток это тот же самый процесс с точки зрения ядра, но это уже совсем другая история), которые суммарно будут у всех процессов в группе.
А также дисковый или сетевой ввод/вывод и некоторые другие специфичные вещи, которые для понимания контрольных групп совершенно не важны.
Контрольные группы: практика
Мозговой штурм
Стандартный путь в файловой системе c директориями контрольных групп находится в /sys/fs/cgroup
. Это, как и procfs
в /proc
, называется cgroupfs
.
Мы уже знакомы с концепцией "необычных" файлов и папок, которые управляются ядром операционной системы, перед нами именно они и есть. Тут нет ничего сложного, нужно просто разобраться что делает каждый из файлов держа в голове, что файлы эти несколько "необычные".
Самое главное:
Контрольная группа - это папка в каталоге
/sys/fs/cgroup
, папки могут быть вложены друг в друга, образуя иерархию контрольных групп;Ресурс - это файл в папке контрольной группы. Они создаются автоматически при создании контрольной группы(папки) или при добавлении нового контроллера как будет показано ниже.
Но важно помнить, что контрольная группа это не просто папка, а абстракция ядра Linux, которая отображается в файловую систему через cgroupfs
в /sys/fs/cgroup
. Но для нас это выглядит как папки с файлами, что даёт удобный интерфейс взаимодействия.
Правда же, не сложно? В этом и есть вся мощь парадигмы "Всё есть файл".

В /sys/fs/cgroup
мы видим корневую контрольную группу. Файл cgroup.controllers
содержит список всех возможных для ограничения ресурсов в принципе. Файл cqroup.subtree_control
содержит список ресурсов(контроллеров в терминологии cgroups), ограничения которых используются в данной контрольной группе(в нашем случае в корневой). Чтобы добавить новый контроллер, нужно просто записать в файл cqroup.subtree_control
строчку вида +ИмяКонтроллера
. После добавления контроллера соответствующие файлы, которые олицетворяют настройки этого ресурса, автоматически появятся в нашей контрольной группе.
В файле cgroup.procs
содержатся процессы, которые находятся в контрольной группе.

Если вы сделаете просто cat cgroup.procs
, то увидите большой список из PID'ов процессов, находящихся в этой контрольной группе. Но нас сейчас интересует именно количество, поэтому я перенаправил вывод в wc -l
.
Кажется очевидным, что количество процессов в корневой контрольной группе должно быть равно количеству процессов в системе в принципе, но почему то это не так, как видно на скриншоте выше.
Всё дело в том, что операционная система создаётся множество системных контрольных групп(например system.slice, а в ней ещё с десяток, как показано на скриншоте выше), а в них ещё... и ещё... так создаётся их иерархия. Если просуммировать все cgroup.procs
из всех дочерних контрольных групп корневой контрольной группы, то полученное число будет равняться числу всех процессов в системе.
Разумеется, на это останавливаться нельзя, давайте напишем скрипт на Python
, который любезно просуммирует значения всех cgroup.procs
во всех вложенных группах корневой контрольной группы:
import os
def count_processes_in_cgroups(root_dir):
total = 0
for dirpath, _, filenames in os.walk(root_dir):
if 'cgroup.procs' in filenames:
try:
with open(os.path.join(dirpath, 'cgroup.procs'), 'r') as f:
total += len(f.readlines())
except (PermissionError, IOError):
continue
return total
if __name__ == "__main__":
total = count_processes_in_cgroups("/sys/fs/cgroup/")
print(total)
А вот и вывод:
zpnst@debian ~/Documents> python3 cg.py
404
zpnst@debian ~/Documents> pgrep -a -c .
403
Как думаете, почему скрипт на питоне насчитал на один процесс больше? Дело в том, что скрипт это тот же процесс, который по умолчанию наследует контрольную группу процесса родителя(моего терминала), который в свою очередь..., ...., ..., который наследует корневую группу процесса init. Думаю, вы поняли суть этих многоточий, тоже самое, что и с Namesapces. Без явного указания процесс попадает с базовые пространства имён или в базовую(корневую) контрольную группу, в которых состоит процесс init(прямо как в нашем примере на C++...). И так как наш скрипт попал в корневую контрольную группу, процессы в которой он хочет посчитать, он учитывает в подсчёте и себя. А специальная утилита pgrep
этого не делает.
Ура, теперь всё встало на свои места. Можно со спокойной душой идти дальше!
На скриншоте выше процессов было меньше, так как пока я писал скрипт что-то в моей системе успело поменяться и количество процессов немного увеличилось. Можете поэкспериментировать с подсчётом сами.
Собственная контрольная группа
Уже неплохо, но давайте создадим чистую контрольную группу, добавим туда процесс, установим лимит памяти и попробуем этот лимит превысить :)
Будет пользоваться этим(в репозитории по ссылке есть и другой пример) примером на питоне из моей реализации контейнерной утилиты, ссылка на которую была в самом начале статьи. Вот он:
import time
buffer = []
def main():
while True:
buffer.append(" " * 100 * 1024 * 1024)
print(f"[{time.time()}] :: 100 MiB was allocated")
if __name__ == "__main__":
main()
Скрипт до боли простой, он просто бесконечно выделяет по 100 Мегабайт.
Но вернёмся к созданию контрольной группы:

Что мы сделали?
Зашли в корневую контрольную группу;
Создали в ней новую контрольную группу
my-new-group
и зашли в неё;Вывели содержимое только что созданной группы(папки), за нас эти файлы были созданы автоматически ядром Linux, как мы и говорили ранее;
Посмотрим какой PID имеет процесс нашего терминала;
Посмотрим какие процессы на данный момент находятся в нашей новой контрольной группе, оказывается, что пока что никаких;
Добавим процесс нашего терминала в группу
my-new-group
;Посмотрим какие процессы теперь содержатся в нашей группе. Первый PID действительно относится к терминалу, а что тут забыл второй? Но и эта идея нам уже знакома. Так как контрольные группы наследуются дочерними процессами, а утилита
cat
является дочерним процессом терминала, то она автоматически попадает в группуmy-new-group
. Второе число есть ничто иное, как PID процессаcat
;Посмотрим лимит памяти - он пока не установлен;
Установим лимит памяти в 512 Мегабайт. Да, просто запишем в файл строчку "512M", всё так просто;
Проверим лимит памяти ещё раз, отлично, у нас получилось его установить;
Проверим лимит страниц, которые могут выгружаться на жёсткий диск в swap раздел;
Поставим значение 0 для таких страниц и проверим. Если мы не сделаем этого, то превысить ограничение в 512 Мегабайт у нас не выйдет, так как при достижении процессом этой планки операционная система будет выгружать страницы памяти в swap раздел на жёстком диске, в этом и есть суть области подкачки(swap).
Мы уже знаем, что процессы наследуют группы своих родителей, поэтому если мы запустим скрипт на питоне, бесконечно выделяющий по 100 Мегабайт, в этом терминале, то он автоматически попадёт в нашу группу my-new-group
.
По идее, его должен убить OOM Killer(о нём мы говорили выше) на шестой попытке выделить 100 Мегабайт, давайте же проверим это:

Снизу наш терминал, с которым мы работали до этого, можете сравнить PID с PID'ом на скриншотах выше. В верхнем терминале выведены логи ядра после завершения скрипта на питоне с помощью утилиты dmesg
.
Скрипт, как и ожидалось, завершился на шестой попытке выделить 100 Мегабайт. В логах ядра мы можем увидеть, что за нашим процессом, который расточительно выделял память, пришёл OOM Killer и безжалостно с ним расправился. Также в логах видно и название нашей контрольной группы my-new-group
.
Вот и всё, если вы дошли до этого момента и поняли всё вышесказанное, то вы осознали практически все фундаментальные механизмы контейнеризации. Но прежде чем перейти к стандартам в этой области и как самому Docker, нам нужно сделать последний небольшой прыжок и понять как работают файловые системы контейнеров и почему Docker называют слоёным пирогом. Уверен, после всего вышесказанного, для вас не составит труда понять это.
OverlayFS: оверлейные файловые системы
Системный вызов mount
Монтируем флешку

Для начала разберёмся что такое монтирование. Мы уже говорили о том, что для того, чтобы наш ПК смог должным образом интерпретировать данные на диске, на нём должна быть знакомая компьютеру разметка. Это стоит держать в голове. C помощью утилиты lsblk
можно посмотреть какие устройства(диски) видны нашей системе. У меня в ноутбуке один жёсткий диск на 1 Терабайт, который разбит на 5 разделов:
zpnst@debian ~> lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
nvme0n1 259:0 0 953.9G 0 disk
├─nvme0n1p1 259:1 0 16G 0 part [SWAP]
├─nvme0n1p2 259:2 0 1G 0 part
├─nvme0n1p3 259:3 0 512M 0 part /boot/efi
├─nvme0n1p4 259:4 0 150G 0 part /
└─nvme0n1p5 259:5 0 786.4G 0 part /home
Справа видны точки монтирования. Например, под корень /
при установке системы я выделил 150 Гигабайт:
├─nvme0n1p4 259:4 0 150G 0 part /
А под /home
(в народе хомяк) всю оставшуюся часть:
└─nvme0n1p5 259:5 0 786.4G 0 part /home
Также, есть ещё несколько стандартных разделов для нормальной работы системы. /boot
с загрузочными файлами операционной системы и область подкачки SWAP
, которую мы уже не раз упоминали. Но это, к сожалению, уже выходит за рамки данной статьи.
Подключим флешку:
zpnst@debian ~> lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
sda 8:0 1 0B 0 disk
sdb 8:16 1 30.2G 0 disk
└─sdb1 8:17 1 30.2G 0 part
nvme0n1 259:0 0 953.9G 0 disk
├─nvme0n1p1 259:1 0 16G 0 part [SWAP]
├─nvme0n1p2 259:2 0 1G 0 part
├─nvme0n1p3 259:3 0 512M 0 part /boot/efi
├─nvme0n1p4 259:4 0 150G 0 part /
└─nvme0n1p5 259:5 0 786.4G 0 part /home
Теперь файл, олицетворяющий флешку лежит по пути /dev/sdb1
(о файловой системе устройств мы уже говорили ранее). Назначается этот файл устройству автоматически, ядром.
Он может называться и иначе, переткнём ту же самую флешку, теперь он в /dev/sdd1
. Короче, это заботы ядра Linux.

А тут мы с помощью mount примонтировали содержимое фелшки в нашу файловую систему в /home/zpnst/Documents/habr
, теперь этот путь называется точкой монтирования. На флешке лежит всего один файл со строчкой "Hello, Habr!". Если мы физически выдернем флешку, то и файл file.txt
исчезнет. Так и работают файлы, которые мы видим в нашей файловой системе, хранящиеся на жёстком диске.
Факт того, что система распознала устройство и что я смог примонтировать и изучить его содержимое говорит о том, что разметка на моей флешке была знакома моей операционной системе и что у меня уже были установлены драйверы, которые умеют эту разметку понимать.
Монтируем директорию

Ещё один пример. Давайте создадим директорию a
и примонтируем её в директорию b
, чтобы все изменения в директории a
автоматически применялись и к директории b
.

Что тут произошло?
Мы создали две директории
a
иb
;Смонтировала директорию
a
в директориюb;
Проверили, что обе директории пока что ничего не содержат;
Создаём файл в директории
a;
Проверяем, что он также появился и в
b
;Тестируем ещё раз, проверяем;
С помощью
umount
удаляем точку монтирования;Как следствие, директория
b
теперь пустая. Ровно также, как и с флешкой, которую мы вытащили из корпуса ПК и в нашей точке монтирования её файлы исчезли.
Тут, опять же, нет никакой магии, всё это возможности ядра. Теперь мы готовы перейти к файловым системам контейнеров!
Файловые системы контейнеров: overlayfs
Лёгкая теория
OverlayFS позволяет накладывать одно дерево каталогов(верхний слой) поверх другого дерева каталогов(нижний слой). Причём нижний слой доступен только для чтения, а верхний ещё и для записи. Подождите, на примерах всё станет ясно.
Для работы overlayfs нужно создать 4 директории:
diff
- директория с изменениями(верхний слой);lower
- директория с базовой структурой файлов, поверх которой будут накладываться изменения(нижний слой);merged
- базовая директорияlower
+ директория с изменениямиdiff
. Тот самый слоёный пирог :)work
- рабочая директория overlayfs, нас она не интересует, просто overlayfs требует её наличия.

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

После создания mycatalogs
мы создаём нужные для работы overlayfs каталоги, о которых говорили выше и используем системный вызов mount
с указанием типа файловой системы с помощью флага -t
. Также, указываем какие директории за что будут отвечать после флага -o
.
Всё, с этого момента эти директории управляются ядром и файловой системой контейнера можно назвать директорию merged
. Как мы видим, ядро автоматически перенесло в merged
все директории из mycatalogs
. Дело в том, что merged
является объединением mycatalogs
и diff
. diff
накладывается поверх mycatalogs
и это отражается в merged
(на картинке в начале этого раздела данная идея наглядно показана).
Попробуем что-нибудь поменять в merged
:

После добавления файла в merged
, он появляется в diff
. Обратите внимание, что mycatalogs
остаётся неизменным. После удаления директории games
она тоже появляется в diff
, но мой терминал почему-то покрасил games
другим цветом. Эта директория немного отличается от других своими атрибутами, не будем на этом останавливаться. Так ядро помечает, что эта папка была именно удалена, а не добавлена(заметьте, папка games
исчезла из merged
).
Для примера добавим в merged
ещё несколько папок:

А теперь подумайте, что будет, если сделать chroot
на директорию merged
?
Стоп, а если ещё и запустить процесс в отдельных пространствах имён и контрольных группах, а с помощью chroot
сделать его корнем директорию merged
?
Верно! Получится контейнер!
В контейнерах за базовое дерево каталогов обычно берут не случайную папку, как наша mycatalogs
, а стандартное дерево каталогов ubuntu
, debian
или alpine Linux
. Такие деревья называются minirootfs
.
На базовый слой с файловой системой, например alpine linux
, можно наложить, например, слой с питоном. Что такое слой с питоном? Ну... это просто исполняемый файл интерпретатора питона в самом простом случае. В папке diff
появится /usr/bin/python
, а базовое дерево каталогов с файловой системой alpine linux
не изменится.
Всё это и называются слоёным пирогом, контейнеры хранят лишь изменения поверх базового дерева каталогов для экономии ресурсов, а при запуске с помощью overlayfs
создаётся merged
, воссоздавая дерево каталогов запущенного контейнера.
Overlayfs с настоящим minirootfs Alpine Linux
Слова словами, давайте сделаем руками всё то, о чем было сказано выше.
По этой ссылке можно скачать minirootfs alpine linux.
Создадим директорию под alpine linux, скачаем архив с minirootfs и распакуем его в только что созданную директорию:
mkdir alpine
wget https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.0-x86_64.tar.gz
tar -xvzf alpine-minirootfs-3.20.0-x86_64.tar.gz -C ./alpine/
Теперь делаем тоже самое, что и в предыдущем примере, только в качестве нижнего слоя указываем alpine.

Думаю, ещё раз результаты объяснять не стоит, они идентичны прошлому примеру.
А теперь запустим процесс bash
и скажем ему, что его корнем дерева каталогов будет директория merged
.
Помните проблемы с запуском chroot
в самом начале статьи? У нас не было исполняемого файла bash
и стандартных библиотек, от которых зависел bash
.
Сейчас же таких проблем, разумеется, быть не должно, ведь в это и суть minirootfs. Она содержит необходимый минимум(окружение) для запуска программ. В папке /bin
в alpine
уже есть bash
и множество других утилит и приложений, а в папке /lib
все стандартные библиотеки.
Вот они:

А теперь chroot
:

Как мы видим, в alpine/bin
не оказалось bash
, зато есть обычный шелл sh
, воспользуемся им. Вот мы и внутри импровизированного контейнера и наш файл hello.cpp
из прошлого примера тоже на месте. Разумеется, если мы добавим новые файлы и папки, все эти изменения отобразятся в diff
.
Теперь другой процесс можно запустить с корнем в другой папке merged
, который будет пользоваться тем же деревом каталогов alpine linux
. Самое главное, что базовую файловую системы alpine
мы не меняем, она для всех одна, как снапшот.
OCI: рассвет контейнеризации
Open Container Initiative
OCI (Open Container Initiative) была анонсирована в июне 2015 года и представляет собой набор открытых стандартов в сфере контейнеризации.

OCI включает две основные спецификации:
Runtime Specification - описывает жизненный цикл контейнеров. То, как они будут исполняться и в какой последовательности будут настраиваться все аспекты их изоляции;
Image Specification - описывает формат образов контейнеров, на основе которых контейнеры запускаются и способы их хранения(тоже самое, что и .exe файл на Windows и процесс, который мы создаём путём запуска этого файла. Или же ELF на Linux).
Эти спецификации обеспечивают совместимость между различными инструментами контейнеризации (Docker, Podman и др.) и упрощают переносимость контейнеров между платформами.
Эталонной реализацией стандартов OCI считается утилита runC, написанная на Golang, которую Docker использует под капотом. Давайте попробуем создать контейнер с помощью неё.
Практика runC
Команда runc spec
создаёт специальный config.json
. В этом json файле хранится информация о namesapces и cgroups нового процесса(контейнера). А также путь до rootfs, env, mounts и т.д. В общем, стандартизированное описание всех механизмов изоляции(и не только её), которые нужно будет применить к новому процессу(контейнеру).
Вот что у нас получится после ввода команды runc spec
. Создастся стандартный шаблонный файл config.json
с самыми простыми настройками.
zpnst@debian ~/D/habr> runc spec
zpnst@debian ~/D/habr> ls
config.json
Теперь рассмотрим поближе некоторые части этого json файла:
{
"ociVersion": "1.2.0",
"process": {
"terminal": true,
"user": {
"uid": 0,
"gid": 0
},
"args": [
"sh"
],
"env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"TERM=xterm"
]
}
В самом начале указывается версия спецификации, в args указывается программа, которая будет запущена в контейнере(по умолчанию это самый обычный шелл), а также переменные окружения.
А в этой части файла мы можем увидеть в каких пространствах имён запустится процесс:
{
"linux": {
"resources": {
"devices": [
{
"allow": false,
"access": "rwm"
}
]
},
"namespaces": [
{
"type": "pid"
},
{
"type": "network"
},
{
"type": "ipc"
},
{
"type": "uts"
},
{
"type": "mount"
},
{
"type": "cgroup"
}
]
}
В базовом шаблоне отсутствует настройка контрольных групп, но она может выглядеть таким образом:
{
"linux": {
"resources": {
"memory": {
"limit": 536870912,
"swap": 536870912
},
"cpu": {
"shares": 512,
"quota": 50000,
"period": 100000
},
"pids": {
"limit": 100
}
}
}
Тут, опять же, нет никакой магии. Просто придумали стандарт, в соответствии с которым описываются все атрибуты будущего контейнера в файле формата json. Потом runC
парсит этот файл и в соответствии с тем, что в нём написано, формирует контейнер.
Теперь нам нужно добавить в в эту же папку уже знакомый нам rootfs
. Давайте воспользуемся тем же alpine Linux
.
zpnst@debian ~/D/habr> ls -l
total 4
drwxr-xr-x 1 zpnst zpnst 114 May 22 2024 alpine/
-rw-r--r-- 1 zpnst zpnst 2500 Aug 3 17:28 config.json
zpnst@debian ~/D/habr> ls alpine
bin/ dev/ etc/ home/ lib/ media/ mnt/ opt/ proc/ root/ run/ sbin/ srv/ sys/ tmp/ usr/ var/
zpnst@debian ~/D/habr>
Содержимое папки habr
, а именно rootfs
и config.json
называется OCI Bundle
, с ним и работает runC
.
Но прежде, чем создать контейнер, нужно кое-что поменять в config.json
:
{
"root": {
"path": "rootfs",
"readonly": true
}
}
По умолчанию runC
будет искать папку с базовой файловой системой контейнера в директории rootfs
, а у нас она называется alpine
. Нужно поменять строчку в поле path c rootfs на alpine.
Теперь мы полностью готовы к запуску контейнера:

В нижнем терминале мы запустили контейнер с помощью runC
, а в верхнем вывели список известных для runC
контейнеров. Обратите внимание на путь до Bundle, он в папке habr
как и предполагалось.
Также я вывел список процессов и сетевые интерфейсы. Обратите внимание, что наш sh
имеет PID 1, то есть процесс в контейнере считает себя главным процессом. И имеет всего один сетевой интерфейс(loopback или localhost). Всё это дело рук Namesapces.
Поздравляю! Теперь вы знаете почти всё о Docker! Некоторые самые тонкие моменты я опустил для того, чтобы статья не превратилась в непроходимые джунгли. Обо всех этих моментах будет написано в самом конце и будут даны полезные ссылки для дополнительного изучения. Но это уже опционально и для тех, кто хочет разобраться досконально в этой теме.
Docker: великий и прекрасный

Зачем нужен Docker
Вот мы и добрались до Docker'а спустя семь с лишним тысяч слов. В прошлом разделе runC
отлично справился с запуском контейнера. Мы также можем управлять контейнерами, смотреть их список, менять настройки, зачем же тогда вообще нужен Docker и чем он лучше?
Как я сказал ранее, Docker пользуется runC
под капотом и вся ценность Docker'а не в самом умении запускать контейнеры(и в этом конечно же тоже), а в его инфраструктуре, обратной совместимости с многими другими инструментами и удобстве(например DockerHub для хранения образов). Хотя, часто за этим удобством кроется и тотальное непонимание того, как он работает. Так было и у меня, так было и у вас, раз вы читаете эту статью.
Играем с китом
Давайте просто поиграем с образами и контейнерами на основе того, что мы уже знаем. Так как просто разбирать архитектуру инфраструктуры Docker'а будет слишком скучно, об этом написано уже очень много материалов без особых технических деталей и экспериментов.
Клонируем образ
Итак, изначально у меня нет ни образов, ни контейнеров, давайте клонируем образ ubuntu
:

Теперь образ появился локально, о чём говорит вывод команды docker images
.
В этой директории /var/lib/docker/image/overlay2/imagedb/content/sha256/<image-id>
хранятся метаданные образов:

Не будем особо вдаваться в подробности каждого из полей, рассмотреть вы их можете и сами.
На данный момент у нас есть просто образ с метаданными, но нет контейнера, давайте же создадим его и поймём где и как лежит его файловая система, конфигурационные файлы и из чего формируется тот самый OCI Bundle, ведь Docker для запуска контейнеров использует runC. runC же, как было сказано выше, работает с OCI Bundle.
Поэтому очевидно, что из конфигурации образа и настроек в Dockerfile
или аргументов командной строки docker run
, Docker должен формировать тот самый config.json
, который будет понятен для runC
.
Запускаем контейнер

Что мы сделали?
Создали контейнер с помощью команды
docker create
(флаги-it
нужны для правильно работы терминала, чтобы терминал процессаbash
в контейнере как бы отображался на наш терминал. Интуитивно понятно и на этом достаточно);Проверяем список контейнеров, теперь там есть контейнер c именем
condescending_nightingale
. Имя контейнера было создано автоматически, так как мы явно не указали его через флаг--name
. Кому интересно, можете взглянуть на исходный код формирования имён для контейнеров. Он весёлый, познавательный и с пасхалками;С помощью
docker start
запускаем контейнер(флаги-ia
, опять же, нужны для терминала, не будем заострять на этом внимание);Ура, мы внутри контейнера, создадим в
/home
файлfiletofind.txt
для того, чтобы отыскать его вне контейнера и понять где и как хранится файловая система нашего контейнера в хостовой системе;Выходим из контейнера с помощью команды
exit
.
Исследуем файловую систему контейнера
А теперь самое интересное, попробуем отыскать файл filetofind.txt
, созданный внутри контейнера, в нашей хостовой системе.

Что мы сделали?
Перешли сразу в
/var/lib/docker
, именно там и хранится большинство файлов, связанных с Docker;Нашли наш файл
filetofind.txt
. Как и ожидалось, он находится в директорииdiff
, так как мы добавили его поверх базового слояubuntu
;В файле
link
хранится уникальный идентификатор нашего слоя;В файле
lower
хранятся идентификаторы нижележащих слоёв через:
.
Посмотреть все слои можно тут: /var/lib/docker/overlay2/l/
.

А теперь следите за руками. Посмотрим что лежит в слое из файла link
(там должен быть filetofind.txt
, то есть наши изменения) и подтвердим гипотезу насчёт того, что нижним слоем является rootfs ubuntu
.

Тому, кому интересно, могут всмотреться в скриншот и убедиться в том, что всё правильно. Или же повторить все эти манипуляции на своём компьютере.
Так! А где директория merged
двумя скриншотами выше? Дело в том, что во время вывода этой информации в терминал, контейнер был не запущен.
Давайте запустим его и посмотрим на результат:

Сначала я ввёл ll
(это тот же ls -l
, только в оболочке fish
). Потом в нижнем терминале запустил контейнер. Потом посмотрел на директории ещё раз. А вот и merged
. Логично, ведь в то время, когда контейнер не исполняется, у нас нет нужды хранить все файлы. Нам достаточно хранить лишь базовую файловую систему(ubuntu
в нашем случае) и изменения поверх неё, которые в неё внёс контейнер(папка diff
). То есть самым нижнем слоем, который можно найти в файле link
будет всегда rootfs ubuntu
для всех контейнеров, запущенных на базе ubuntu
!
Находим OCI Bundle
По пути /var/lib/docker/containers/<container-id>
хранятся постоянные файлы контейнера. Они будут там находится, пока мы явно не удалим контейнер. Тут находятся как базовые конфигурации контейнера, так и специфичные для Docker настройки:

А вот сам config.json
(скриншот ниже), такой же, как мы формировали командной runc spec
.
В Docker он формируется на лету при запуске контейнера как комбинация тех конфигураций(предыдущий скриншот) и пользовательских настроек. Хранится конечный config.json
, на основе которого и запускается контейнер с помощью runC
в /run/containerd/io.containerd.runtime.v2.task/moby/<container-id>
:

Но как только мы остановим контейнер(не удалим, просто остановим, прервём его рантайм) все файлы из /run/containerd/io.containerd.runtime.v2.task/moby/<container-id>
исчезнут. При запуске config.json
соберётся вновь.
Тонкие моменты, которые мы не разобрали
Торжественно заявляю, что вы обрели контекст работы с Docker, а если прочитали статью больше одного раза и экспериментировали вместе со мной, то скорее всего поняли как и зачем всё это нужно :)
Тут я обещал осветить моменты, которые я опустил.
Rootless контейнеры
Начнём с того, что все смены пространств имён и запуск контейнеров через runc
мы осуществляли от имени суперпользователя. На самом деле, в случае с unshare
можно обойтись и без рута, создав новый User Namespace, тем самым заставив процесс думать, что рутом являет он. Это не слишком важно в контексте данной статьи. Работа User Namespaces не слишком очевидна, поэтому в списке литературы я оставлю ссылку на лекцию от Майкла Керриска на эту тему.
Docker контейнеры запускаются без рута по другой причине. Существует специальный фоновый процесс(демон) containerd
, работающий от имени рута, которому docker cli
делегирует все привилегированные операции.
Capabilities
Права обычных пользователей очень ограничены, в то время как права пользователя root очень обширны. Хотя процессам, запущенным под root, часто не требуются все полномочия root. Для уменьшения полномочий пользователя root были придуманы capabilities. Это способ ограничить список привилегированных системных операций, которые разрешено выполнять процессу и его потомками.
По сути, они делят все root-права на набор отдельных привилегий. Capabilities очень часто используются для более тонкой настройки контейнера и указываются в том самом config.json
в соответствии со спецификацией OCI. Этот механизм очень специфичен и требует знание многих концепций Linux. Ссылку на лекцию про capabilities от Майкла Керриска я также оставлю в списке литературы.
Реализация контейнерной утилиты Containy на Golang

Не буду подробно разбирать тут код, потому что не все знают Golang и не всем это будет интересно, а дам лишь краткое описание. В репозитории в файле README.md вы можете найти подробное описание работы утилиты с примерами.
Она похожа на упрощённую версию runC
. Также парсится config.json
, который там называется configy.json
и на его основе создаётся контейнер. Но, разумеется, утилита не реализует спецификации OCI, так как она была задумана как "академическая игрушка".
Containy
умеет запускать процесс в новых пространствах имён и настраивать для него ограничения контрольных групп. А также работать с overlayfs
и настраивать стандартные точки монтирования, такие как /proc
.
Заключение
Спасибо вам, если дошли до сюда. Это была моя первая статья на Хабре и я очень надеюсь, что она смогла научить вас чему-то новому.
Главное, что стоило вынести из этой статьи о механизмах контейнеризации - все в этой сфере держится на стандартах и возможностях ядра Linux, тут нет никакой магии.
В статье мы разобрали:
Chroot как первый популярный механизм, с помощью которого можно изолировать процесс в контексте файловой системы;
Namespaces как механизм, представляющий собой прослойку между желанием процесса получить ресурс и самим ресурсом;
Cgroups как ещё один механизм изоляции процессов, но только уже в контексте физических ресурсов системы;
OverlayFS как способ экономить место за счёт хитрой работы с файловыми системами контейнеров. Самое главное, поняли почему Docker слоёный пирог :);
Стандарт OCI как вещь, на которой держится вся современная контейнерная инфраструктура и утилиту runC, являющуюся эталонной реализацией OCI;
А также поиграли с Docker на низком уровне и поняли, что контейнер это не что-то инопланетное, а просто процесс, перед запуском которого Docker предварительно настраивает всё то, о чём мы говорили в статье. Вдобавок поняли где и как хранятся образы и контейнеры в хостовой системе.
Надеюсь, вы узнали много нового не только о Docker, но и о Linux в целом.
Список Литературы
Habr:
YouTube:
Michael Kerrisk :: Understanding Linux user namespaces(тут и про capabilities)
An introduction to control groups (cgroups) version 2 - Michael Kerrisk - NDC TechTown 2021