Мой первый контейнер без Docker
https://habr.com/ru/articles/881428/
Технологии контейнеризации, возможно, как и у большинства читателей данной статьи, плотно засели в моей голове. И казалось бы, просто пиши 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, который сейчас не работает. Но это уже каждый сам решит, как с этим играть.
Спасибо за прочтение!