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

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

Из каких трёх системных вызовов состоит весь интерфейс пространств имён

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

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

Создание изолированной точки монтирования через отделение выглядит компактно, и в нём виден весь принцип:

#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>

int main(void) {
    /* отделяем точки монтирования: дальше монтирования видим только мы */
    if (unshare(CLONE_NEWNS) == -1) {
        perror("unshare");
        return 1;
    }
    /* отсюда монтирования этого процесса невидимы остальной системе */
    return 0;
}

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

Почему пространство идентификаторов процессов требует дополнительного ветвления

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

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

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

Чем пространство пользователей особенно и почему оно не требует прав

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

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

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

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

Как пространства связаны между собой и с межпроцессным обменом

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

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

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

Какие подводные камни всплывают при сборке собственной изоляции

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

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

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

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