Pull to refresh

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

Reading time7 min
Views99K
Original author: Ifeanyi Ubah

Часть 1
Часть 2
Часть 3
Часть 4


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


Что такое namespace?


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


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


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


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


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


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


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


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


$ 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

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


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

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


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


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


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


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


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


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


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


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

Реализация


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


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


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


  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);

    // Создание пайпа для связи между основным и командным процессом.
    if (pipe(params.fd) < 0)
        die("Failed to create pipe: %m");

    // Клонирование командного процесса.
    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");

    // Получить доступный к записи конец пайпа.
    int pipe = params.fd[1];

    // Тут будут размещаться некоторые настройки namespace ...

    // Сигнал командному процессу, что мы закончили с настройкой.
    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;
}

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


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


static int cmd_exec(void *arg)
{
    // Убить процесс cmd если процесс isolate умирает.
    if (prctl(PR_SET_PDEATHSIG, SIGKILL))
        die("cannot PR_SET_PDEATHSIG for child process: %m\n");

    struct params *params = (struct params*) arg;
    // Ожидание сигнала 'настройка завершена' от основного процесса.
    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
# Проверьте в новом окне терминала, что имя хоста не изменилось

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

Tags:
Hubs:
Total votes 39: ↑39 and ↓0+39
Comments9

Articles