[Перевод] Linux-контейнеры в паре строчек кода
В продолжение прошлой статьи о KVM публикуем новый перевод и разбираемся, как работают контейнеры на примере запуска Docker-образа busybox.
Эта статья о контейнерах является продолжением предыдущей статьи о KVM. Я бы хотел показать, как именно работают контейнеры, запустив Docker-образ busybox в нашем собственном небольшом контейнере.
В отличие от понятия «виртуальная машина», термин «контейнер» очень расплывчатый и неопределенный. Обычно мы называем контейнером — автономный пакет кода со всеми необходимыми зависимостями, которые могут поставляться вместе и запускаться в изолированной среде внутри операционной системы хоста. Если вам кажется, что это описание виртуальной машины, давайте погрузимся в тему глубже и рассмотрим, как реализованы контейнеры.
BusyBox Docker
Нашей главной целью будет запустить обычный образ busybox для Docker, но без Docker. Docker использует btrfs в качестве файловой системы для своих образов. Давайте попробуем скачать образ и распаковать его в директорию:
mkdir rootfs
docker export $(docker create busybox) | tar -C rootfs -xvf -
Теперь у нас есть файловая система образа busybox, распакованная в папку rootfs. Конечно, можно запустить ./rootfs/bin/sh и получить рабочую shell-оболочку, но если мы посмотрим на список процессов, файлов, или сетевых интерфейсов, увидим, что у нас есть доступ ко всей ОС.
Итак, давайте попробуем создать изолированную среду.
Clone
Поскольку мы хотим контролировать то, к чему имеет доступ дочерний процесс, мы будем использовать clone (2) вместо fork (2). Clone делает почти то же самое, но позволяет передавать флаги, указывая, какие ресурсы вы хотите разделять (с хостом).
Разрешены следующие флаги:
- CLONE_NEWNET — изолированные сетевые устройства
- CLONE_NEWUTS — имя хоста и домена (система разделения времени UNIX)
- CLONE_NEWIPC — объекты IPC
- CLONE_NEWPID — идентификаторы процессов (PID)
- CLONE_NEWNS — точки монтирования (файловые системы)
- CLONE_NEWUSER — пользователи и группы.
В нашем эксперименте мы попытаемся изолировать процессы, IPC, сетевые и файловые системы. Итак, начинаем:
static char child_stack[1024 * 1024];
int child_main(void *arg) {
printf("Hello from child! PID=%d\n", getpid());
return 0;
}
int main(int argc, char *argv[]) {
int flags =
CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWNET;
int pid = clone(child_main, child_stack + sizeof(child_stack),
flags | SIGCHLD, argv + 1);
if (pid < 0) {
fprintf(stderr, "clone failed: %d\n", errno);
return 1;
}
waitpid(pid, NULL, 0);
return 0;
}
Код должен запускаться с привилегиями суперпользователя, иначе клонирование не удастся.
Эксперимент дает любопытный результат: дочерний PID равен 1. Нам хорошо известно, что PID 1 обычно у процесса init. Но в этом случае дочерний процесс получает свой собственный изолированный список процессов, где он стал первым процессом.
Рабочая оболочка
Чтобы упростить изучение новой среды, запустим shell-оболочку в дочернем процессе. Давайте запускать произвольные команды, наподобие docker run:
int child_main(void *arg) {
char **argv = (char **)arg;
execvp(argv[0], argv);
return 0;
}
Теперь запуск нашего приложения с аргументом /bin/sh открывает настоящую оболочку, в которой мы сможем вводить команды. Такой результат доказывает, насколько мы ошибались, говоря об изолированности:
# echo $$
1
# ps
PID TTY TIME CMD
5998 pts/31 00:00:00 sudo
5999 pts/31 00:00:00 main
6001 pts/31 00:00:00 sh
6004 pts/31 00:00:00 ps
Как мы видим, сам процесс shell-оболочки имеет PID равный 1, но, на самом деле, он может видеть и получать доступ ко всем другим процессам основной ОС. Причина в том, что список процессов читается из procfs, которая все еще наследуется.
Итак, размонтируем procfs:
umount2("/proc", MNT_DETACH);
Теперь при запуске shell-оболочки ломаются команды ps, mount и другие, потому что procfs не смонтирована. Однако это все равно лучше, чем утечка родительской procfs.
Chroot
Для создания корневого каталога обычно применяется chroot, но мы воспользуемся альтернативой — pivot_root. Этот системный вызов переносит текущий корень системы в подкаталог и назначает другую директорию корнем:
int child_main(void *arg) {
/* Unmount procfs */
umount2("/proc", MNT_DETACH);
/* Pivot root */
mount("./rootfs", "./rootfs", "bind", MS_BIND | MS_REC, "");
mkdir("./rootfs/oldrootfs", 0755);
syscall(SYS_pivot_root, "./rootfs", "./rootfs/oldrootfs");
chdir("/");
umount2("/oldrootfs", MNT_DETACH);
rmdir("/oldrootfs");
/* Re-mount procfs */
mount("proc", "/proc", "proc", 0, NULL);
/* Run the process */
char **argv = (char **)arg;
execvp(argv[0], argv);
return 0;
}
Имеет смысл смонтировать tmpfs в /tmp, sysfs в /sys и создать действующую файловую систему /dev, но для краткости я пропущу этот шаг.
Теперь мы видим только файлы из образа busybox, как будто мы использовали chroot:
/ # ls
bin dev etc home proc root sys tmp usr var
/ # mount
/dev/sda2 on / type ext4 (rw,relatime,data=ordered)
proc on /proc type proc (rw,relatime)
/ # ps
PID USER TIME COMMAND
1 root 0:00 /bin/sh
4 root 0:00 ps
/ # ps ax
PID USER TIME COMMAND
1 root 0:00 /bin/sh
5 root 0:00 ps ax
На данный момент контейнер выглядит вполне изолированным, возможно, даже слишком. Мы не можем ничего пинговать, а сеть, похоже, вообще не работает.
Сеть
Создать новое сетевое пространство имен было только началом! Нужно назначить ему сетевые интерфейсы и настроить их для правильной пересылки пакетов.
Если у вас нет интерфейса br0, необходимо создать вручную (brctl является частью пакета bridge-utils в Ubuntu):
brctl addbr br0
ip addr add dev br0 172.16.0.100/24
ip link set br0 up
sudo iptables -A FORWARD -i wlp3s0 -o br0 -j ACCEPT
sudo iptables -A FORWARD -o wlp3s0 -i br0 -j ACCEPT
sudo iptables -t nat -A POSTROUTING -s 172.16.0.0/16 -j MASQUERADE
В моем случае, wlp3s0 был основным сетевым интерфейсом WiFi, а 172.16.x.x — сетью для контейнера.
Наша программа запуска контейнеров должна создать пару интерфейсов, veth0 и veth1, связать их с br0 и настроить маршрутизацию внутри контейнера.
В функции main () мы запустим перед клонированием эти команды:
system("ip link add veth0 type veth peer name veth1");
system("ip link set veth0 up");
system("brctl addif br0 veth0");
По окончании вызова clone () мы добавим veth1 в новое дочернее пространство имен:
char ip_link_set[4096];
snprintf(ip_link_set, sizeof(ip_link_set) - 1, "ip link set veth1 netns %d",
pid);
system(ip_link_set);
Теперь, если мы запустим ip link в оболочке контейнера, мы увидим интерфейс loopback и некоторый интерфейс veth1@xxxx. Но сеть по-прежнему не работает. Зададим уникальное имя хоста в контейнере и настроим маршруты:
int child_main(void *arg) {
....
sethostname("example", 7);
system("ip link set veth1 up");
char ip_addr_add[4096];
snprintf(ip_addr_add, sizeof(ip_addr_add),
"ip addr add 172.16.0.101/24 dev veth1");
system(ip_addr_add);
system("route add default gw 172.16.0.100 veth1");
char **argv = (char **)arg;
execvp(argv[0], argv);
return 0;
}
Посмотрим, как это выглядит:
/ # ip link
1: lo: mtu 65536 qdisc noop qlen 1
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
47: veth1@if48: mtu 1500 qdisc noqueue qlen 1000
link/ether 72:0a:f0:91:d5:11 brd ff:ff:ff:ff:ff:ff
/ # hostname
example
/ # ping 1.1.1.1
PING 1.1.1.1 (1.1.1.1): 56 data bytes
64 bytes from 1.1.1.1: seq=0 ttl=57 time=27.161 ms
64 bytes from 1.1.1.1: seq=1 ttl=57 time=26.048 ms
64 bytes from 1.1.1.1: seq=2 ttl=57 time=26.980 ms
...
Работает!
Вывод
Полный исходный код доступен по ссылке. Если вы обнаружили ошибку или у вас есть какое-то предложение, оставьте, пожалуйста, комментарий!
Безусловно, Docker способен на гораздо большее! Но удивительно, сколько подходящих API имеет ядро Linux и как легко их использовать, чтобы достичь виртуализации на уровне ОС.
Надеюсь, вам понравилась статья. Вы можете найти проекты автора на Github и подписаться на Twitter, чтобы следить за новостями, а также через rss.