Перевели для вас статью про то, как с нуля создать Linux-контейнер, аналогичный тому, который можно запустить с помощью Docker, но без использования Docker или других инструментов контейнеризации. Передаём слово автору.
Недавно я собрал клон Docker на Go. Это заставило меня задуматься — насколько сложно будет сделать то, что делает Docker, в обычном терминале? Что ж, давайте узнаем!
Если решите повторять за мной, настоятельно рекомендую завести виртуальную машину Linux. Мы будем выполнять кучу команд под root’ом — не хотелось бы случайно угробить ваши системы.
Файловая система Linux-контейнера
Здесь буду краток. О том, что такое контейнерные файловые системы, особенно overlayFS, я рассказал в предыдущей статье. Фактически мы создаём структуру директорий для контейнера, загружаем minirootfs на основе Alpine и монтируем её с помощью overlayFS:
# Создаём структуру папок во временной директории.
mkdir -p /tmp/container-1/{lower,upper,work,merged}
cd /tmp/container-1
# Скачиваем alpine-minirootfs.
wget https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.3-x86_64.tar.gz
tar -xzf alpine-minirootfs-3.20.3-x86_64.tar.gz -C lower
# Монтируем OverlayFS; корневая директория контейнера будет в /tmp/container-1/merged
sudo mount -t overlay overlay -o lowerdir=lower,upperdir=upper,workdir=work mergedПосле запуска должна получиться такая структура директорий:
michal@michal-lg:/tmp/container-1$ ls
alpine-minirootfs-3.20.3-x86_64.tar.gz lower merged upper workСам контейнер будет использовать /tmp/container-1/merged в качестве корневой директории:
michal@michal-lg:/tmp/container-1/merged$ ls
bin etc lib mnt proc run srv tmp var
dev home media opt root sbin sys usrКонтрольные группы (cgroups) Linux
Ограничим потребление ресурсов для контейнера. Выделим ему, к примеру, 100m CPU и 500 MiB памяти.
Настроить cgroups очень просто:
# Создаём новый слайс cgroup и дочернюю cgroup для нашего контейнера.
sudo mkdir -p /sys/fs/cgroup/toydocker.slice/container-1
cd /sys/fs/cgroup/toydocker.slice/
# Включаем возможность менять CPU и память для дочерней cgroup.
sudo -- sh -c 'echo "+memory +cpu" > cgroup.subtree_control'
cd container-1
# Устанавливаем максимальное использование процессора на 10 %.
sudo -- sh -c 'echo "10000 100000" > cpu.max'
# Устанавливаем лимит памяти на 500 MiB.
sudo -- sh -c 'echo "500M" > memory.max'
# Отключаем своп.
sudo -- sh -c 'echo "0" > memory.swap.max'Синтаксис cpu.max выглядит необычно. Смысл в том, из 100 000 единиц времени эта cgroup может потреблять 10 000 единиц. Чтобы ограничить cgroup двумя CPU, мы написали бы 200 000 и 100 000.
Интересно, что правило cpu.max не ограничивает процесс одним физическим ядром. Так что на 4-ядерной машине процесс может использовать по 2500 единиц времени на каждом из четырёх ядер. Для ограничения количества используемых физических ядер можно использовать cpusets.
Видно, что при создании группы cgroup автоматически были заданы правила по умолчанию:
michal@michal-lg:/sys/fs/cgroup/toydocker.slice/container-1$ ls
cgroup.controllers cpu.pressure memory.numa_stat
cgroup.events cpu.stat memory.oom.group
cgroup.freeze cpu.stat.local memory.peak
cgroup.kill cpu.uclamp.max memory.pressure
cgroup.max.depth cpu.uclamp.min memory.reclaim
cgroup.max.descendants cpu.weight memory.stat
cgroup.pressure cpu.weight.nice memory.swap.current
cgroup.procs io.pressure memory.swap.events
cgroup.stat memory.current memory.swap.high
cgroup.subtree_control memory.events memory.swap.max
cgroup.threads memory.events.local memory.swap.peak
cgroup.type memory.high memory.zswap.current
cpu.idle memory.low memory.zswap.max
cpu.max memory.max memory.zswap.writeback
cpu.max.burst memory.minДавайте проверим, что наши изменения вступили в силу:
michal@michal-lg:/sys/fs/cgroup/toydocker.slice/container-1$ cat cpu.max
10000 100000
michal@michal-lg:/sys/fs/cgroup/toydocker.slice/container-1$ cat memory.max
524288000
michal@michal-lg:/sys/fs/cgroup/toydocker.slice/container-1$ cat memory.swap.max
0Так и есть. Теперь разберёмся, как поместить процесс в cgroup и дополнительно изолировать его с помощью пространств имён.
Пространства имён
Для начала разберёмся, зачем нужны пространства имён, а затем посмотрим, как они используются.
Если cgroups — это основной механизм ограничения использования ресурсов, то пространства имён — это основной механизм изоляции самих ресурсов.
В качестве примера рассмотрим монтирование файловой системы. При монтировании новой файловой системы на хосте она становится видимой для всех процессов. Чтобы избежать конфликтов, необходимо знать, какие ещё файловые системы и куда примонтированы. С пространством имён каждый процесс может вносить изменения в файловую систему по своему усмотрению, не влияя на процессы за пределами этого пространства имён.
То же самое справедливо и для других ресурсов: сети, межпроцессное взаимодействие, идентификаторы процессов, пользователей и так далее.
Теперь, когда мы разобрались с сутью, посмотрим, как всё работает:
# Входим в интерактивный root-режим.
sudo -i
# Добавляем текущий процесс в cgroup.
echo $$ > /sys/fs/cgroup/toydocker.slice/container-1/cgroup.procs
# Создаём новые пространства имён.
unshare \
--uts \
--pid \
--mount \
--mount-proc \
--net \
--ipc \
--cgroup \
--fork \
/bin/bashЭтот фрагмент кода немного запутан, но для меня главное — сохранить всё в одном терминале.
Сначала мы входим в интерактивный режим root. Это связано с тем, что нам нужно выполнить две следующие команды с правами root и из одной консоли:
michal@michal-lg:~$ # Входим в интерактивный root-режим.
sudo -i
[sudo] password for michal:
root@michal-lg:~#Вторая команда добавляет текущий процесс консоли в cgroup, которую мы создали ранее. Все дочерние процессы этого процесса также будут автоматически добавлены в cgroup:
root@michal-lg:~# echo $$
28156
root@michal-lg:~# echo $$ > /sys/fs/cgroup/toydocker.slice/container-1/cgroup.procsКогда мы это делаем, текущая консоль попадает в cgroup и применяются все ограничения по процессору и памяти, которые мы установили ранее.
Далее при создании пространства имён форкается текущий процесс и запускается Bash. Подробнее о команде unshare можно узнать из man-страниц.
root@michal-lg:~# unshare \
--uts \
--pid \
--mount \
--mount-proc \
--net \
--ipc \
--cgroup \
--fork \
/bin/bash
root@michal-lg:~#Кажется, что не произошло ничего особенного, но на самом деле мы создали полноценный контейнер с помощью cgroup и пространства имён. Давайте убедимся, что пространство имён UTS работает правильно. Для этого изменим hostname и посмотрим, что произойдёт на хосте.
Терминал контейнера:
root@michal-lg:~# hostname
michal-lg
root@michal-lg:~# hostname mycontainer
root@michal-lg:~# hostname
mycontainer
root@michal-lg:~#Терминал хоста:
michal@michal-lg:~$ hostname
michal-lgПоскольку используется PID-пространство имён, у /bin/bash должен быть ID = 1. Проверим из контейнера:
root@michal-lg:~# ps
PID TTY TIME CMD
1 pts/1 00:00:00 bash
32 pts/1 00:00:00 psА теперь посмотрим, какой у процесса ID на хосте:
michal@michal-lg:~$ ps -ef | grep -i /bin/bash
root 8952 8932 0 16:10 pts/1 00:00:00 unshare --uts --pid --mount --mount-proc --net --ipc --cgroup --fork /bin/bash
root 8953 8952 0 16:10 pts/1 00:00:00 /bin/bashНа этом этапе перед запуском приложения рантайм контейнера выполняет некоторые дополнительные действия. Давайте рассмотрим их.
Настройка на стороне контейнера
Прежде всего контейнер изолируется от файловой системы хоста — с помощью команды pivot_root меняется корневая директория.
pivot_root — это более безопасный эквивалент chroot/tmp/container-1/merged, позволяющий избежать breakout-эксплойтов. Безопасность — не моя специализация, поэтому приведу ссылку на статью, в которой объясняется, как эти эксплойты работают и как pivot_root их предотвращает.
root@michal-lg:~# cd /tmp/container-1/merged
mount --make-rprivate /
mkdir old_root
pivot_root . old_root
umount -l /old_root
rm -rf /old_root
root@michal-lg:/tmp/container-1/merged#Отдельная корневая директория не даёт контейнеру повлиять на таблицу монтирования хоста, что также можно было бы использовать для эксплойтов.
В терминале нужно выполнить cd .., чтобы обновить состояние после удаления старой корневой директории, поскольку в результате этого переменные PATH больше не работают.
Но поскольку мы находимся в директории /tmp/container-1/merged, а её файловая система основана на alpine-minirootfs, в директории bin есть основные утилиты.
root@michal-lg:/tmp/container-1/merged# cd ..
root@michal-lg:/# ls
bash: /usr/bin/ls: No such file or directory
root@michal-lg:/# /bin/ls
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr varДавайте также настроим основные устройства, которые понадобятся нам в дальнейшем, и смонтируем полезные виртуальные файловые системы:
mknod -m 666 dev/null c 1 3
mknod -m 666 dev/zero c 1 5
mknod -m 666 dev/tty c 5 0/bin/mkdir -p dev/{pts,shm}
/bin/mount -t devpts devpts dev/pts
/bin/mount -t tmpfs tmpfs dev/shm
/bin/mount -t sysfs sysfs sys/
/bin/mount -t tmpfs tmpfs run/
/bin/mount -t proc proc proc/Если бы мы не примонтировали proc, не было бы доступа к информации о процессах. Команды, зависящие от этой информации, не смогли бы работать:
root@michal-lg:/# top
top: no process info in /procПосле монтирования всё снова заработало:
Mem: 7560280K used, 8661696K free, 161756K shrd, 135464K buff, 2364264K cached
CPU: 0% usr 0% sys 0% nic 98% idle 0% io 0% irq 0% sirq
Load average: 0.30 0.38 0.37 1/1233 64
PID PPID USER STAT VSZ %VSZ CPU %CPU COMMAND
1 0 root S 12896 0% 6 0% /bin/bash
64 1 root R 1624 0% 9 0% topНа этом этапе можно настроить работу с сетью, экспортировать переменные окружения и так далее. Для наших же целей всё готово, пора запускать пользовательское приложение.
Предположим, пользователь хочет запустить простую интерактивную оболочку. Сделать это можно так:
exec /bin/busybox shЯ использую busybox, поскольку он работает как минимальный init-скрипт и идёт в составе alpine-minirootfs. exec заменяет старый Shell-процесс новым.
root@michal-lg:/# exec /bin/busybox sh
/ # ls
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
/ #Сейчас мы примерно там, где оказались бы, выполнив следующую Docker-команду:
michal@michal-lg:~$ docker run -it --cpus="0.1" --memory="512M" --memory-swap=0 --entrypoint /bin/sh --rm alpine
/ # ls
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
/ #Работа с контейнером
Наконец, давайте убедимся, что установленные ранее ограничения cgroup работают.
Для этого сначала запустим задачу, которая «скушает» все ресурсы ядра CPU:
/ # while true; do true; doneА теперь откроем терминал на хосте, чтобы увидеть реальную загрузку процессора. Сначала найдём ID процесса:
michal@michal-lg:~$ ps -ef | grep -i busybox
root 8953 8952 0 16:10 pts/1 00:00:07 /bin/busybox shА теперь воспользуемся командой top и убедимся, что загрузка процессора не превышает 10 %:
michal@michal-lg:~$ top -p 8953
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
8953 root 20 0 1696 1024 896 R 10.0 0.0 0:15.25 busybox Чтобы проверить, что соблюдаются лимиты контейнера по памяти, воспользуемся устройством /dev/zero. Команда tail будет читать из него нулевые байты в буфер в памяти. Вскоре тот превысит лимит в 500 MiB и контроллер памяти cgroup убьёт процесс.
/ # tail /dev/zero
KilledТеперь можно выходить из контейнера. Уберём за собой, отмонтировав корневую директорию /tmp/container-1/merged:
michal@michal-lg:/tmp/container-1$ sudo umount mergedНа этом всё! Мы с нуля создали контейнер в терминале.
Заключение
Основной вывод — в контейнерах нет ничего волшебного. Это не виртуальные машины, а всего лишь результат применения крутой изоляции процессов, встроенной в ядро Linux. Изоляция достигается с помощью cgroups и пространств имён.
Полный список команд можно посмотреть в моём репозитории на GitHub.
Надеюсь, вы узнали что-нибудь новое. Если так, подпишитесь. А ещё я всегда рад пообщаться с читателями на LinkedIn и BlueSky.
P. S.
Читайте также в нашем блоге:
