![Tux с новой игрушкой Tux с новой игрушкой](https://habrastorage.org/getpro/habr/upload_files/60c/896/5e3/60c8965e382cc5f70c90acfbceedb093.png)
Технологии контейнеризации, возможно, как и у большинства читателей данной статьи, плотно засели в моей голове. И казалось бы, просто пиши Dockerfile и не выпендривайся. Но всегда же хочется узнавать что-то новое и углубляться в уже освоенные темы. По этой причине я решил разобратьсяв реализации контейнеров в ОС на базе ядра linux и в последствие создать свой «контейнер» через cmd.
На ком держатся контейнеры в Linux?
![На страже На страже](https://habrastorage.org/getpro/habr/upload_files/04f/792/66c/04f79266c01251e7261925e89d690932.png)
Для начала необходимо понять на чем именно основана технология контейнеризации. В ядре 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
![Tux волшебник?! Tux волшебник?!](https://habrastorage.org/getpro/habr/upload_files/6f2/f25/f14/6f2f25f14260c94f5428c932e99ef6ec.png)
Мы разобрались с основной необходимой нам теорией. Теперь перейдем к практике и прибегнем к волшебству командной строки.
Для начала создадим структуру файловой системы контейнера, установим 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, который сейчас не работает. Но это уже каждый сам решит, как с этим играть.
Спасибо за прочтение!
Комментарии (12)
vikarti
11.02.2025 13:42Интересно а кто нибудь еще помнит что такое Virtuozzo и позднее - OpenVZ и какое это отношение имеет с тем же cgroups/namespaces ?
Stanislavvv
11.02.2025 13:42Что-то никто не вспоминает https://github.com/p8952/bocker — Docker implemented in around 100 lines of bash.
domix32
11.02.2025 13:42Меня интересует несколько иной вопрос - вот у нас есть образа с alpine, void, arch, ubuntu - как они выглядят с точки зрения докера и исполняются в контексте конкретных дистров, а не как кусок хостовой системы.
Ascard
Было бы неплохо ещё в конец статьи добавить как это дело всё прибить если что-то внутри пойдёт не так. И как удалить, не снеся случайно половину хостовой системы. Докер как-то это сам делает, а тут есть риск появления упсь!-моментов у начинающих контейнероводов.
А за статью спасибо, небезынтересно было узнать как оно работает.
lantonov
Согласен как-то с докером спокойней это делать тан как в докер есть готовые файловые системы и методы удаления и создания контейнера есть ещё более взрослый вариант то lxd