Механізми контейнеризації: namespaces

namespaces

Останні кілька років відзначені зростанням популярності «контейнерних» рішень для ОС Linux. Про тому, як і для яких цілей можна використовувати контейнери, сьогодні багато говорять і пишуть. А ось механізмів, що лежать в основі контейнеризації, приділяється значно менше уваги.

Всі інструменти контейнеризації — чи то Docker, LXC або systemd-nspawn,— ґрунтуються на двох підсистемах ядра Linux: namespaces і cgroups. Механізм namespaces (просторів імен) ми хотіли б докладно розглянути в цієї статті.

Почнемо трохи здалеку. Ідеї, що лежать в основі механізму просторів імен, не нові. Ще в 1979 році в UNIX був доданий системний виклик chroot()   з метою забезпечити ізоляцію та надати розробникам окрему від основної системи майданчик для тестування. Незайвим буде згадати, як він працює. Потім ми розглянемо особливості функціонування механізму просторів імен в сучасних Linux-системах.

Chroot(): перша спроба ізоляції
Назва chroot являє собою скорочення від change root, що дослівно перекладається як «змінити корінь». З допомогою системного виклику chroot() відповідної команди можна змінити кореневий каталог. Запущеної з зміненим кореневим каталогом, будуть доступні тільки файли, що знаходяться в цьому каталозі.

Файлова система UNIX представляє собою деревоподібну ієрархічну структуру:

chroot

Вершиною цієї ієрархії є каталог /, він ж root. Всі інші каталоги   usr, local, bin і інші,   пов'язані з ним.

З допомогою chroot в систему можна додати другий кореневий каталог, який з точки зору користувача нічим не буде відрізнятися від першого. Файлову систему, якою присутній змінений кореневий каталог, можна схематично подати так:

chroot

Файлова система розділена на дві частини, і вони ніяк не впливають один на одного. Як працює chroot? Спочатку звернемося до вихідного коду. В якості прикладу розглянемо реалізацію chroot в OC 4.4 BSD-Lite.

Системний виклик chroot описаний у файлі vfs_syscall.c:

сhroot(p, uap, retval)
struct proc *p;
struct chroot_args *uap;
int *retval;
{
register struct filedesc *fdp = p>p_fd;
int error;
struct nameidata nd;

if (error = suser(p->p_ucred, &p->p_acflag))
return (error);
NDINIT(&nd, LOOKUP, FOLLOW | LOCKLEAF, UIO_USERSPACE, uap->path, p);
if (error = change_dir(&nd, p))
return (error);
if (fdp->fd_rdir != NULL)
vrele(fdp->fd_rdir);
fdp->fd_rdir = nd.ni_vp;
return (0);
}


Найголовніше відбувається в передостанньому рядку наведеного нами фрагмента: поточна директорія стає кореневої.
У ядрі Linux системний виклик chroot реалізований дещо складніше (фрагмент коду взято звідси):

SYSCALL_DEFINE1(chroot, const char __user *, filename)
{
struct path path;
int error;
unsigned int lookup_flags = LOOKUP_FOLLOW | LOOKUP_DIRECTORY;
retry:
error = user_path_at(AT_FDCWD, filename, lookup_flags, &path);
if (error)
goto out;

error = inode_permission(path.dentry->d_inode, MAY_EXEC | MAY_CHDIR);
if (error)
goto dput_and_out;

error = -EPERM;
if (!ns_capable(current_user_ns(), CAP_SYS_CHROOT))
goto dput_and_out;
error = security_path_chroot(&path);
if (error)
goto dput_and_out;

set_fs_root(current->fs, &path);
error = 0;
dput_and_out:
path_put(&path);
if (retry_estale(error, lookup_flags)) {
lookup_flags |= LOOKUP_REVAL;
goto retry;
}
out:
return error;
}

Розглянемо особливості роботи chroot в Linux на практичних прикладах. Виконаємо наступні команди:

$ mkdir test
$ chroot test /bin/bash

У внаслідок виконання другої команди ми отримаємо повідомлення про помилку:

chroot: failed to run command '/bin/bash': No such file or directory

Помилка полягає в наступного: не була знайдена командна оболонка. Звернемо увагу на цей важливий момент: з допомогою chroot ми створюємо нову, ізольовану файлову систему, яка не має ніякого доступу до поточної. Спробуємо знову:

$ mkdir test/bin
$ cp /bin/bash test/bin
$ chroot test
chroot: failed to run command '/bin/bash': No such file or directory

Знову помилка   незважаючи на ідентичне повідомлення, зовсім не така, як у минулого разу. Минуле повідомлення було видав шелл, так як не знайшов потрібного виконуваного файлу. У прикладі вище про помилку повідомив динамічний програма компонування: він не знайшов необхідних бібліотек. Щоб отримати до них доступ, їх теж потрібно копіювати в chroot. Подивитися, які саме динамічні бібліотеки потрібно скопіювати, можна так:

$ ldd /bin/bash
linux-vdso.so.1 => (0x00007fffd08fa000)
libtinfo.so.5 => /lib/x86_64-linux-gnu/libtinfo.so.5 (0x00007f30289b2000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f30287ae000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f30283e8000)
/lib64/ld-linux-x86-64.so.2 (0x00007f3028be6000)

Після цього виконаємо наступні команди:

$ mkdir test/lib test/lib64
$ cp /lib/x86_64-linux-gnu/libtinfo.so.5 test/lib/
$ cp /lib/x86_64-linux-gnu/libdl.so.2 test/lib/
$ cp /lib64/ld-linux-x86-64.so.2 test/lib64/
$ cp /lib/x86_64-linux-gnu/libc.so.6 test/lib
$ chroot test
bash-4.3# 

Тепер вийшло! Спробуємо виконати в нової файлової системи, наприклад, команду ls:

bash-4.3# ls

У відповідь ми отримаємо повідомлення про помилку:

bash: ls: command not found

Причина зрозуміла: в нової файлової системи команда ls відсутня. Потрібно знову копіювати виконуваний файл і динамічні бібліотеки, як це вже було показано вище. У і полягає серйозний недолік chroot: всі необхідні файли потрібно дублювати. Є у chroot і ряд недоліків з точки зору безпеки.

Спроби удосконалити механізм chroot і забезпечити більш надійну ізоляцію робилися неодноразово: так, в зокрема, з'явилися такі відомі технології, як FreeBSD Jail Solaris Zones.
У ядрі Linux ізоляція процесів була вдосконалена завдяки додаванню нових підсистем і нових системних викликів. Деякі з них ми розберемо нижче.

Механізм просторів імен
Простір імен (англ. namespace) — це механізм ядра Linux, що забезпечує ізоляцію процесів один від друга. Робота з його реалізації була розпочата у версії ядра 2.4.19. На поточний момент в Linux підтримується шість типів просторів імен:
Простір імен Що ізолює
PID PID процесів
NETWORK Мережеві пристрої, стеки, порти і т. п.
USER ID користувачів і груп
MOUNT монтування
IPC SystemV IPC, черги повідомлень POSIX
UTS Ім'я хоста і доменне ім'я NIS
Всі ці типи використовуються сучасними системами контейнеризації (Docker, LXC і іншими) при запуску програм.

PID: ізоляція PID процесів
Історично в ядрі Linux підтримувалося лише одне дерево процесів. Дерево процесів являє собою ієрархічну структуру, подібну дереву каталогів файлової системи.

З появою механізму namespaces стала можливою підтримка декількох дерев процесів, повністю ізольованих один від друга.

При завантаженні в Linux спочатку запускається процес з ідентифікаційним номером (PID) 1. У дереві процесів він є кореневим. Він запускає інші процеси і служби. Механізм namespaces дозволяє створювати окреме відгалуження дерева процесів з власним PID 1. Процес, який створює таке відгалуження, є частиною основного дерева, але його дочірній процес вже буде кореневим в новому дереві.

Процеси в новому дереві ніяк не взаємодіють з батьківським процесом і не «бачать» його. &Nbsp; водночас процесів в основному дереві доступні всі процеси дочірнього дерева. Наочно це показано на наступною схемою:

PID Namespace

Можна створювати кілька вкладених просторів імен PID: один процес запускає дочірній процес в новому просторі імен PID, a в свою чергу породжує новий процес у новому просторі і тощо

Один і  ж процес може мати кілька ідентифікаторів PID (окремий ідентифікатор для окремого простору імен).

Для створення нових просторів імен PID використовується системний виклик clone() c прапором CLONE_NEWPID. З допомогою цього прапора можна запускати новий процес у новому просторі імен і в новому дереві. Розглянемо в якості прикладі невелику програму на мовою C:


#define _GNU_SOURCE
#include <sched.h>
#include < stdio.h>
#include <stdlib.h>
#include < sys/wait.h>
#include <unistd.h>

static char child_stack[1048576];

static int child_fn() {
printf("PID: %ld\n", (long)getpid());
return 0;
}

int main() {
pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL);
printf("clone() = %ld\n", (long)child_pid);

waitpid(child_pid, NULL, 0);
return 0;
}

Скомпилируем і запустимо цю програму. &Nbsp;завершення її виконання ми побачимо наступний висновок:

clone() = 9910
PID: 1

Під час виконання такої маленької програми в системі відбулося багато цікавого. Функція clone() створила новий процес, клонировав поточний, і початку його виконання. При цьому вона відокремила новий процес від основного дерева і створила для нього окреме дерево процесів.

Тепер спробуємо змінити код програми і дізнатися батьківський PID з точки зору ізольованого процесу:

static int child_fn() {
printf("Батьківський PID: %ld\n", (long)getppid());
return 0;
}

Висновок зміненої програми буде виглядає так:

clone() = 9985
Батьківський PID: 0

Рядок «Батьківський PID: 0» означає, що у розглянутого нами процесу батьківського процесу немає. Внесемо в програму ще одна зміна і приберемо прапор CLONE_NEWPID з виклику clone():

pid_t child_pid = clone(child_fn, child_stack+1048576, SIGCHLD, NULL);

Системний виклик clone в цьому випадку спрацював практично так  fork() просто створив новий процес. Між fork() і clone(), однак є суттєва відмінність, яке слід розібрати детально.

Fork() створює дочірній процес, який представляє копію батьківського. Батьківський процес копіюється разом з всім контекстом виконання: виділеною пам'яттю, відкритими файлами і тощо

У відміну від fork() виклик clone() не просто створює копію, але дозволяє розділяти елементи контексту виконання між дочірнім та батьківським процесами. У наведеному вище прикладі коду з функцію clone використовується аргумент child_stack, який задає положення стека для дочірнього процесу. Як тільки дочірній і батьківський процеси можуть розділяти пам'ять, дочірній процес не може виконуватися в , ж стеку, що і батьківський. Тому батьківський процес повинен встановити простір пам'яті для дочірнього і передати вказівник на нього в виклик clone(). Ще один аргумент, який використовується з функцію clone() — це прапори, які вказують, що саме потрібно розділяти між батьківським і дочірнім процесами. У наведеному нами прикладі використаний прапор CLONE_NEWPID, який вказує, що дочірній процес повинен бути створений в новому просторі імен PID. Приклади використання інших прапорів будуть наведені нижче.

Отже, ізоляцію на рівні процесів ми розглянули. Але це   всього лише перший крок. Запущений у окремому просторі імен процес все одно буде мати доступ до всіх системних ресурсів. Якщо такий процес буде слухати, наприклад, 80-й порт, це цей порт буде заблоковано для всіх інших процесів. Уникнути таких ситуацій допомагають інші простори імен.

NET: ізоляція мереж
Завдяки простору імен NET ми можемо виділяти для ізольованих процесів власні мережеві інтерфейси. Навіть loopback-це інтерфейс для кожного простору імен буде окремим.

Мережеві простору імен можна створювати з допомогою системного виклику clone () прапором CLONE_NEWNET. Також це можна зробити з допомогою iproute2:

$ ip netns add netns1

Скористаємося strace і подивимося, що сталося в системі під час наведеної команди:

.....
socket(PF_NETLINK, SOCK_RAW|SOCK_CLOEXEC, 0) = 3
setsockopt(3, SOL_SOCKET, SO_SNDBUF, [32768], 4) = 0
setsockopt(3, SOL_SOCKET, SO_RCVBUF, [1048576], 4) = 0
bind(3, {sa_family=AF_NETLINK, pid=0, groups=00000000}, 12) = 0
getsockname(3, {sa_family=AF_NETLINK, pid=1270, groups=00000000}, [12]) = 0
mkdir("/var/run/netns", 0755) = 0
mount("", "/var/run/netns", "ні", MS_REC|MS_SHARED, NULL) = -1 EINVAL (Invalid argument)
mount("/var/run/netns", "/var/run/netns", 0x4394fd, MS_BIND, NULL) = 0
mount("", "/var/run/netns", "ні", MS_REC|MS_SHARED, NULL) = 0
open("/var/run/netns/netns1", O_RDONLY|O_CREAT|O_EXCL, 0) = 4
close(4) = 0
unshare(CLONE_NEWNET) = 0
mount("/proc/self/ns/net", "/var/run/netns/netns1", 0x4394fd, MS_BIND, NULL) = 0
exit_group(0) = ?
+++ exited with 0 +++

Звернемо увагу: тут для створення нового просторі імен використаний системний виклик unshare(), не вже знайомий нам clone. Unshare() дозволяє процесу або треду відокремлювати частини контексту виконання, спільні з іншими процесами (або тредами).

Як можна поміщати процеси в нове мережеве простір імен?

По-перше, процес, який створив нове простір імен, може породжувати інші процеси, і кожен з цих процесів буде успадковувати мережеве простір імен батьків.

По-друге, в ядрі є спеціальний системний виклик   setns(). З його допомогою можна помістити викликає процес або тред в потрібний простір імен. Для цього потрібно файловий дескриптор, який на це простір імен посилається. Він зберігається в файлі /proc/<PID процесу>/ns/net. Відкривши цей файл можемо передати файловий дескриптор функції setns().

Можна піти і іншим шляхом. При створенні нового простору імен з допомогою команди ip створюється файл каталозі /var/run/netns/ (див. у виведення трасування вище). Щоб отримати файловий дескриптор, досить просто відкрити цей файл.

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

MOUNT: ізоляція файлової системи
Про ізоляції на рівні файлової системи ми вже згадували вище, коли розбирали системний виклик chroot (). Ми відзначили, що системний виклик chroot() не забезпечує надійної ізоляції. З допомогою ж просторів імен MOUNT можна створювати повністю незалежні файлові системи, асоційовані з різними процесами:

MOUNT namespace

Для ізоляції файлової системи використовується системний виклик clone() c прапором CLONE_NEWNS:

clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL)

Спочатку дочірній процес «бачить» ті ж точки монтування, що і батьківський. Як тільки дочірній процес перенесений в окремий простір імен, до нього можна примонтувати будь-яку файлову системи, і це ніяк не торкнеться ні батьківський процес, ні інші простори імен.

Інші простори імен
Ізольований процес також може бути поміщений в інші простори імен: UID, IPC та PTS. UID процесу дозволяє отримувати привілеї root межах певного простору імен. З допомогою простору імен IPC можна ізолювати ресурси для комунікації між процесами.

UTS використовується для ізоляції системних ідентифікаторів: ім'я вузла (вузла) і імені домену (domainame), що повертаються системним викликом uname(). Розглянемо ще одну невелику програму:

#define _GNU_SOURCE
#include <sched.h>
#include < stdio.h>
#include <stdlib.h>
#include < sys/utsname.h>
#include < sys/wait.h>
#include <unistd.h>


static char child_stack[1048576];

static void print_nodename() {
struct utsname utsname;
uname(&utsname);
printf("%s\n", utsname.вузла);
}

static int child_fn() {
printf("Нове ім'я: ");
print_nodename();

printf("Ім'я буде змінено в новому просторі імен!\n");
sethostname("NewOS", 6);

printf("Нове ім'я сайту: ");
print_nodename();
return 0;
}

int main() {
printf("Первісне ім'я вузла: ");
print_nodename();

pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWUTS | SIGCHLD, NULL);

sleep(1);

printf("Первісне ім'я вузла: ");
print_nodename();

waitpid(child_pid, NULL, 0);

return 0;
}

Висновок цієї програми буде виглядати так:

Первісне ім'я вузла: lilah
Нове ім'я сайту: lilah
Ім'я буде змінено в новому просторі імен!
New UTS namespace вузла: NewOS

Як бачимо, функція child_fn() виводить ім'я вузла, змінює його, а потім виводить вже нове ім'я. Зміна відбувається тільки всередині нового простору імен.

Висновок
У цій статті ми в загальних рисах розглянули, як працює механізм namespaces. Сподіваємося, вона допоможе вам краще зрозуміти принципи роботи контейнерів. &Nbsp;традиції наводимо посилання на цікаві додаткові матеріали:


Розгляд механізмів контейнеризації ми обов'язково продовжимо. У наступній публікації ми розповімо про механізм cgroups.

Якщо ви з тих чи інших причин не можете залишати коментарі тут — запрошуємо у наш блог.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.