Хабрахабр

[Перевод] Глубокое погружение в Linux namespaces

В процессе мы создадим более простой клон команды docker run – нашу собственную программу, которая будет принимать на входе команду (вместе с её аргументами, если таковые имеются) и разворачивать контейнер для её выполнения, изолированный от остальной системы, подобно тому, как вы бы выполнили docker run для запуска из образа. В этой серии постов мы внимательно рассмотрим один из главных ингредиентов в контейнере – namespaces.

Что такое namespace?

Мы можем думать об namespace, как о ящике. Linux namespace – это абстракция над ресурсами в операционной системе. В настоящее время существует семь типов пространств имён (namespaces): Cgroups, IPC, Network, Mount, PID, User, UTS. В этом ящике находятся системные ресурсы, которые точно зависят от типа ящика (namespace).

Таким образом, два экземпляра Network namespace A и B (соответствующие двум ящикам одного типа в нашей аналогии) могут содержать различные ресурсы – возможно, A содержит wlan0, тогда как B содержит eth0 и отдельную копию таблицы маршрутизации. Например, Network namespace включает в себя системные ресурсы, связанные с сетью, такие как сетевые интерфейсы (например, wlan0, eth0), таблицы маршрутизации и т.д., Mount namespace включает файлы и каталоги в системе, PID содержит ID процессов и так далее.

Они предоставляются самим ядром Linux и уже являются необходимостью для запуска любого процесса в системе. Пространства имён (namespaces) – не какая-то дополнительная фича или библиотека, которую вам нужно установить, например, с помощь пакетного менеджера apt. Поэтому, когда ему требуется сказать «обнови таблицу маршрутизации в системе», Linux показывает ему копию таблицы маршрутизации namespace, к которому он принадлежит в этот момент. В любой данный момент времени любой процесс P принадлежит ровно одному экземпляру namespace каждого типа.

Для чего это нужно?

Одним их замечательных свойств ящиков является то, что вы можете добавлять и удалять вещи из ящика и это никак не повлияет на содержимое других ящиков. Абсолютно ни для чег… конечно, я просто пошутил. Тут та же идея с namespaces – процесс P может «сойти с ума» и выполнить sudo rm –rf /, но другой процесс Q, принадлежащий другому Mount namespace, не будет затронут, поскольку они они используют отдельные копии этих файлов.

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

Ограничение — это забота

Типичным для Linux образом они отображаются как файлы в каталоге /proc/$pid/ns данного процесса с process id $pid: Мы можем видеть namespaces, которым принадлежит процесс!

$ ls -l /proc/$$/ns
total 0
lrwxrwxrwx 1 iffy iffy 0 May 18 12:53 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 iffy iffy 0 May 18 12:53 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 iffy iffy 0 May 18 12:53 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 iffy iffy 0 May 18 12:53 net -> net:[4026531957]
lrwxrwxrwx 1 iffy iffy 0 May 18 12:53 pid -> pid:[4026531836]
lrwxrwxrwx 1 iffy iffy 0 May 18 12:53 user -> user:[4026531837]
lrwxrwxrwx 1 iffy iffy 0 May 18 12:53 uts -> uts:[4026531838]

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

Во втором терминале мы можем выполнить что-то вроде этого: Давайте немного вмешаемся в это.

$ hostname
iffy
$ sudo unshare -u bash
$ ls -l /proc/$$/ns
lrwxrwxrwx 1 root root 0 May 18 13:04 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 May 18 13:04 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 root root 0 May 18 13:04 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 root root 0 May 18 13:04 net -> net:[4026531957]
lrwxrwxrwx 1 root root 0 May 18 13:04 pid -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 May 18 13:04 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 May 18 13:04 uts -> uts:[4026532474]
$ hostname
iffy
$ hostname coke
$ hostname
coke

Флаг -u говорит ей запустить bash в новом UTS namespace. Команда unshare запускает программу (опционально) в новом namespace. Обратите внимание, что наш новый процесс bash указывает на другой файл uts, тогда как все остальные остаются прежними.

Здесь и далее мы будем считать, что как unshare, так и наша реализация выполняются с помощью sudo. Создание новых namespaces обычно требует доступа с правами суперпользователя.

Вы можете проверить это, выполнив hostname в первом терминале и увидев, что имя хоста там не изменилось. Одним из следствий того, что мы только что проделали, является то, что теперь мы можем изменить системный hostname из нашего нового процесса bash и это не повлияет ни на какой другой процесс в системе.

Но что, например, такое контейнер?

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

И, как мы видели, Linux добавит этот процесс участником дефолтного Network namespace, как и любой другой обычный процесс. Например, когда вы набираете docker run --net=host redis, всё, что вы делаете — говорите докеру не создавать новый Network namespace для процесса redis. Это возможность настройки не только сети, docker run позволяет вам делать такие изменения для большей части существующих namespaces. Таким образом, с точи зрения сети процесс redis точно такой же, как и все остальные. Остаётся ли контейнером процесс, использующий все, кроме одного, общие namespace? Тут возникает вопрос, что же такое контейнер? ¯\_(ツ)_/¯ Обычно контейнеры идут вместе с понятием изоляции, достигаемой через namespaces: чем меньше количество namespaces и ресурсов, которые процесс делит с остальными, тем более он изолирован и это всё, что действительно имеет значение.

Изолирование

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

Остальные же будут относительно тривиальны для реализации после того, как мы закончим (фактически, мы добавим поддержку UTS здесь в первичной реализации программы). В зависимости от области применения, мы сфокусируемся на User, Mount, PID и Network namespaces. А рассмотрение, например, Cgroups, выходит за рамки этой серии (изучение cgroups — другого компонента контейнеров, используемого для управления тем, сколько ресурсов может использовать процесс).

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

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

Реализация

Наша реализация isolate будет простой программой, которая считывает строку с командой из stdin и клонирует новый процесс, выполняющий её с указанными аргументами. Исходный код для этого поста можно найти здесь. В следующих постах мы увидим, что namespaces не обязательно работают (или хотя бы обеспечивают изоляцию) из коробки и нам нужно будет выполнить некоторую настройку после их создания (но перед реальным запуском команды), чтобы команда действительно выполнялась изолированной. Клонированный процесс с командой будет выполняться в собственном UTS namespace точно также, как мы делали это ранее с unshare.

В результате часть основной работы здесь будет заключаться в настройке связующего канала между обоими процессами — в нашем случае мы будем использовать Linux pipe из-за его простоты. Эта комбинация создания-настройки namespace потребует некоторого взаимодействия между основным процессом isolate и дочерним процессом запускаемой команды.

Нам нужно сделать три вещи:

  1. Создать основной процесс isolate, читающий данные из stdin.
  2. Клонировать новый процесс, который будет запускать команду в новом UTS namespace.
  3. Настроить пайп таким образом, чтобы процесс выполнения команды начинал её запуск только после получения сигнала от основного процесса о завершении настройки namespace.

Вот основной процесс:

int main(int argc, char **argv)
{ struct params params; memset(&params, 0, sizeof(struct params)); parse_args(argc, argv, &params); // Create pipe to communicate between main and command process. if (pipe(params.fd) < 0) die("Failed to create pipe: %m"); // Clone command process. int clone_flags = SIGCHLD | CLONE_NEWUTS ; int cmd_pid = clone(cmd_exec, cmd_stack + STACKSIZE, clone_flags, &params); if (cmd_pid < 0) die("Failed to clone: %m\n"); // Get the writable end of the pipe. int pipe = params.fd[1]; // Some namespace setup will take place here ... // Signal to the command process we're done with setup. if (write(pipe, "OK", 2) != 2) die("Failed to write to pipe: %m"); if (close(pipe)) die("Failed to close pipe: %m"); if (waitpid(cmd_pid, NULL, 0) == -1) die("Failed to wait pid %d: %m\n", cmd_pid); return 0;
}

Видите, как просто создать процесс в его собственном namespace? Выход с clone_flags мы передаём нашему вызову clone. Всё, что нам нужно сделать, это установить флаг для типа namespace (CLONE_NEWUTS флаг соответствует UTS namespace), а Linux позаботится об остальном.

Далее процесс команды ожидает сигнала перед её запуском:

static int cmd_exec(void *arg)
{ // Kill the cmd process if the isolate process dies. if (prctl(PR_SET_PDEATHSIG, SIGKILL)) die("cannot PR_SET_PDEATHSIG for child process: %m\n"); struct params *params = (struct params*) arg; // Wait for 'setup done' signal from the main process. await_setup(params->fd[0]); char **argv = params->argv; char *cmd = argv[0]; printf("===========%s============\n", cmd); if (execvp(cmd, argv) == -1) die("Failed to exec %s: %m\n", cmd); die("¯\\_(ツ)_/¯"); return 1;
}

Наконец, мы может попробовать это запустить:

$ ./isolate sh
===========sh============
$ ls
isolate isolate.c isolate.o Makefile
$ hostname
iffy
$ hostname coke
$ hostname
coke
# Проверьте в новом окне терминала, что имя хоста не изменилось

В следующем посте мы сделаем еще один шаг, рассмотрев User namespaces заставим isolate выполнять команду в собственном User namespace. Сейчас isolate — это немногим больше, чем программа, которая просто форкает команду (у нас есть UTS, работающий для нас). Там мы увидим, что на самом деле надо проделать некоторую работу, чтобы иметь пригодный к использованию namespace, в котором может выполняться команда.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»