
Технологии контейнеризации, возможно, как и у большинства читателей данной статьи, плотно засели в моей голове. И казалось бы, просто пиши Dockerfile и не выпендривайся. Но всегда же хочется узнавать что-то новое и углубляться в уже освоенные темы. По этой причине я решил разобраться в реализации контейнеров в ОС на базе ядра linux и впоследствии создать свой «контейнер» через cmd.
На ком держатся контейнеры в Linux?

Для начала необходимо понять на чем именно основана технология контейнеризации. В ядре Linux существуют два механизма: namespace (пространство имен), cgroups (control groups). Они обеспечивают изоляцию и масштабируемость, за которые мы все так любим контейнеры. Давайте разберем по порядку оба механизма.
Namespace
Пространства имен позволяют нам изолировать ресурсы системы между процессами. С их помощью мы можем создать отдельную виртуальную систему, при этом формально находясь в хостовой. Возможно, данное краткое пояснение не особо просветило Вас, поэтому давайте взглянем на пример:
Рассмотрим контейнер поднятый из образа alpine. Запустим его и интерактивную оболочку в нем:
docker run -it alpine /bin/sh
Теперь создадим новый процесс в контейнере, и проверим вывод команды ps:
sleep 1000 & ps -a
Получаем:
PID USER TIME COMMAND 1 root 0:00 /bin/sh 29 root 0:00 sleep 1000 30 root 0:00 ps -a
Обратите внимание, что PID процесса равен 29. Теперь попробуем найти этот же процесс, но на хостовой машине. Для этого определим ID контейнера и воспользуемся командой для отображения процессов, запущенных внутри docker:
docker top <ID контейнера>
В результате получаем:
UID PID PPID C STIME TTY TIME CMD root 172147 172124 0 Feb05 pts/0 00:00:00 /bin/sh root 173602 172147 0 Feb05 pts/0 00:00:00 sleep 1000
Обратим внимание на 2 столбца: PID и PPID(parent PID). Они указывают на PID самого процесса и родительского, но уже в хостовой системе. Давайте проверим это:
ps aux | grep -E '173602|172147'
Получаем:
root 172147 0.0 0.0 1736 908 pts/0 Ss+ Feb05 0:00 /bin/sh root 173602 0.0 0.0 1624 980 pts/0 S Feb05 0:00 sleep 1000
Что и требовалось доказать! Если подытожить, то можно сделать вывод, что контейнер ничего не знает про хостовую машину. Он считает, что является самостоятельной системой. Однако, в действительности все процессы запущены на хосте, просто они находятся в пространстве имен данного контейнера. Это и создает иллюзию отдельной независимой системы.
Надеюсь, данный пример чуть прояснил ситуацию с namespace. В нем мы разобрали один из 8 видов пространств имен. Теперь хотелось бы кратко пройтись по каждому:
Mount- изоляция точек монтирования файловой с��стемы. Позволяет установить свою иерархию фс;UTS- изоляция имени хоста. Позволяет для каждого контейнера указать свое хостовое имя;PID- изоляция идентификаторов процессов. Позволяет создавать отдельное дерево процессов;Network- изоляция сетевых интерфейсов, таблиц маршрутизации;IPC- изоляция IPC(межпроцессные взаимодействия);User- изоляция пользователей системы. Позволяет создавать отдельных пользователей для каждого контейнера, в том числе и root;Cgroup- изоляция доступа к cgroup. Позволяет ограничивать ресурсы контейнера и предотвращает вмешательства других контейнеров;Time- изоляция системного времени.
Для создания нового namespace в Linux существует команда unshare. С ней мы чуть позже познакомимся ближе.
Cgroups
Control groups - механизм ядра Linux, позволяющий управлять ресурсами процессов. С его помощью можно ограничить и изолировать использование CPU, памяти, сети, диска.
Существует две версии cgoups: v1 и v2. В большинстве современных систем вы встретите вторую версию, которая используется в работе systemd. Основное отличие версий в построении дерева ограничений. В первой версии создавались узлы для каждого вида ограничений, а в них уже добавлялись группы. Во второй версии для каждой группы свой узел, внутри которого все необходимые ограничения. Чтобы лучше понять, давайте взглянем на визуализацию деревьев v1 и v2:
#v1 /sys/fs/cgroup/ ├── cpu │ ├── group1/ │ │ ├── tasks │ │ ├── cgroup.procs │ │ ├── cpu.shares │ │ └── ... │ ├── group2/ │ │ ├── tasks │ │ ├── cgroup.procs │ │ ├── cpu.shares │ │ └── ... │ └── ... ├── memory │ ├── group1/ │ │ ├── tasks │ │ ├── cgroup.procs │ │ ├── memory.limit_in_bytes │ │ └── ... │ ├── group2/ │ │ ├── tasks │ │ ├── cgroup.procs │ │ ├── memory.limit_in_bytes │ │ └── ... │ └── ... └── ... #v2 /sys/fs/cgroup/ ├── group1/ │ ├── cgroup.procs │ ├── cpu.max │ ├── cpu.weight │ ├── memory.current │ ├── memory.max │ └── ... ├── group2/ │ ├── cgroup.procs │ ├── cpu.max │ ├── cpu.weight │ ├── memory.current │ ├── memory.max │ └── ... └── ...
Теперь давайте взглянем на работу cgroup на примере контейнера docker. Для начала запустим контейнер, ограничив его ресурсы(2 ядра и 512МБ):
docker run -d --cpus="2" --memory="512m" nginx
Далее найдем группу для этого контейнера, воспользовавшись командой find :
find /sys/fs/cgroup -name '*<ID контейнера>*'
Далее проверим содержание файлов cpu.max и memory.max в найденной директории:
# cpu.max 200000 100000 # memory.max 536870912
Что и требовалось доказать!
Создание контейнера без docker

Мы разобрались с основной необходимой нам теорией. Теперь перейдем к практике и прибегнем к волшебству командной строки.
Для начала создадим структуру файловой системы контейнера, установим busybox в директорию /bin :
# Создаем корневую директорию контейнера и переходим в нее mkdir ~/container && cd ~/container # Создаем основные системные директории и переходим в /bin mkdir -p ./{proc,sys,dev,tmp,bin,root,etc} && cd bin # Устанавливаем busybox wget https://www.busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox # Выдаем право на исполнение chmod +x busybox # Создаем симлинки для всех команд, которые есть в busybox ./busybox --list | xargs -I {} ln -s busybox {} # Возвращаемся в корневую директорию контейнера cd ~/container # Добавляем переменную PATH в файл /etc/profile echo 'export PATH=/bin' > ~/container/etc/profile
Также добавим в файлы /etc/passwd и /etc/group, чтобы внутри изолированной системы мы были рутом:
echo "root:x:0:0:root:/root:/bin/sh" > ~/container/etc/passwd echo "root:x:0:" > ~/container/etc/group
Далее смонтируем системные директории:
# Монтируем устройства, используя уже существующие sudo mount --bind /dev ~/container/dev # Монтируем процессы sudo mount -t proc none ~/container/proc # Монтируем файловую систему sysfs sudo mount -t sysfs none ~/container/sys # Монтируем файлов��ю систему tmpfs sudo mount -t tmpfs none ~/container/tmp
Примечание: для того чтобы потом размонтировать можно воспользоваться командой:
sudo umount ~/container/{proc,sys,dev,tmp}
Мы подготовили файловую систему для нашего контейнера. Теперь перейдем к созданию изоляции. Для этого мы воспользуемся командой:
unshare -f -p -m -n -i -u -U --map-root-user --mount-proc=./proc \ /bin/chroot ~/container /bin/sh -c "source /etc/profile && exec /bin/sh"
Давайте разберем ее подробнее:
-f- fork. Создаем новый процесс для изоляции от родительского;-p- PID namespace;-m- mount namespace;-n- Network namespace;-i- IPC namespace;-u- UTS namespace;-U- User namespace;--map-root-user- маппинг uid и gid активного пользователя на root внутри контейнера;-mount-proc- монтируем proc внутри контейнера;/bin/chroot ~/container- меняем корневую директорию;/bin/sh -c "source /etc/profile && exec /bin/sh"- запускаем shell и исполняем команду, которая применит файл/etc/profileи запустит интерактивный shell.
Отлично! Мы получили свой контейнер. Теперь осталось ограничить ресурсы. Для этого откроем новую сессию на хосте и выполним ряд действий:
# Создаем новую группу. В моей системе используется cgroups v2, поэтому # директория автоматически будет настроена для работы с ресурсами sudo mkdir /sys/fs/cgroup/my_container # Записываем ограничение на 2 ядра процессора echo "200000 100000" | sudo tee /sys/fs/cgroup/my_container/cpu.max # Выделяем максимум 512MB памяти echo 536870912 | sudo tee /sys/fs/cgroup/my_container/memory.max
Далее необходимо определить PID контейнера, для этого воспользуемся командой:
ps aux | grep -E '/bin/sh$'
Берем PID из второго столбца и добавляем в файл cgroup.procs :
echo <PID> | sudo tee /sys/fs/cgroup/my_container/cgroup.procs
На этом основные настройки закончены. Мы создали изолированную систему и добавили ограничение ресурсов. Но хотелось бы сделать ее чуть более функциональной, для этого настроим виртуальную сеть между хостом и контейнером:
# Создаем пару виртуальных интерфейсов sudo ip link add veth-host type veth peer name veth-container # Поднимаем интерфейс на хосте sudo ip link set veth-host up # Назначаем любой свободный адрес в вашей сети на интерфейс хоста # Я использую 192.168.1.123/24 sudo ip addr add 192.168.1.123/24 dev veth-host # Перемещаем veth-container в пространство имен контейнера # Здесь нужно указать PID контейнера, который использовали до этого sudo ip link set veth-container netns <PID> # Поднимаем интерфейс внутри контейнер sudo nsenter --net=/proc/<PID>/ns/net ip link set veth-container up # Назначаем любой свободный адрес в вашей сети на интерфейс контейнера # Я использую 192.168.1.124/24 sudo nsenter --net=/proc/<PID>/ns/net ip addr add 192.168.1.124/24 dev veth-container # Настраиваем шлюз по умолчанию для маршрутизации трафика sudo nsenter --net=/proc/<PID>/ns/net ip route add default via 192.168.1.123
Мы подняли все нужные интерфейсы. Теперь необходимо настроить маршрутизацию:
# Разрешаем пересылку пакетов echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward # Добавляем правило NAT для маскарадинга для исходящих пакетов из сети # 192.168.1.0/24 через интерфейс который смотрит во внешнюю сеть. У меня это enp3s0. # Маскарадинг маскирует пакеты исходящие их контейнера так, чтобы они выглядели, # как пакеты отправленные с хоста sudo iptables -t nat -A POSTROUTING -s 192.168.1.0/24 -o enp3s0 -j MASQUERADE # Добавляем правило на разрешение форвардинга пакетов sudo iptables -A FORWARD -s 192.168.1.0/24 -o enp3s0 -j ACCEPT # Добавляем правило, разрешающее входящие пакеты sudo iptables -A FORWARD -d 192.168.1.0/24 -m state --state RELATED,ESTABLISHED -j ACCEPT
Отлично! Мы создали свой первый контейнер. Понятно, что в нем еще много чего можно настроить, тот же DNS, который сейчас не работает. Но это уже каждый сам решит, как с этим играть.
Спасибо за прочтение!
