Перевели для вас статью про то, как с нуля создать 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.
Читайте также в нашем блоге:
Комментарии (16)
tnimraeps
14.02.2025 06:34Неоднократно сталкивался с утверждением, что ПО в докере работает медленнее, чем чистая установка. Если это так и докер под капотом делает всё тоже самое, что описано в статье, то откуда берётся это замедление?
FlashHaos
14.02.2025 06:34Мне кажется, как и многие другие утверждения «работает медленно», оно не основывается ни на каких объективных метриках, а основывается чисто на ощущениях и интуиции.
Сходу нашел статью где разница в производительности контринтуитивно тем больше, чем меньше нагрузка. Но причины понять не могу.
tnimraeps
14.02.2025 06:34За ссылку спасибо. Интересно будет поискать на досуге и для других веб-серверов.
pvzh
14.02.2025 06:34Надо уточнять, о какой ОС речь. Если Линукс, то замедление и дополнительно занимаемое место меньше, чем то же самое под другими ОС.
AlterMax
14.02.2025 06:34Медленнее, накладные расходы на изоляцию процессов и ФС, плюс явные ограничения на выделяемые для контейнера ресурсы. Накладные расходы не критичны в большинстве случаев, но они есть
FlashHaos
14.02.2025 06:34А что такое «накладные расходы на изоляцию»? Я так понял, что сам механизм cgroups итак всегда работает, просто по умолчанию группа большая и одна.
AlterMax
14.02.2025 06:34В основе подобных штук всегда лежит некий фильтр, чем шире диапазон этого фильтра, тем медленнее он работает, ИМХО это очевидно. Кроме того существуют различные оптимизации, например если фильтр "пустой" его можно отключить. И т.п.
kolserdav
14.02.2025 06:34Оно не медленнее работает, просто на работу самого докера нужны ресурсы, и если у системы с ресурсами проблема то это будет отражаться и на приложении.
AlterMax
14.02.2025 06:34Интересно, но к сожаление не очень явно освещен один из главных моментов - откуда в контейнере берется собственно операционная система? Поскольку явно не отражено, что используется ядро ОС хоста, может показаться, что в контейнере запускается полностью самостоятельная Alpine Linux. Отсюда часто возникает путанница между виртуализацией и контейнеризацией.
re4t1rt998
14.02.2025 06:34Пуру дней назад вышла статья прям на эту же тему
gudvinr
14.02.2025 06:34Это, кажется, она и есть. Просто тут указали, что это перевод, а там сделали так же, просто по-другому и удалили все ссылки на оригинального автора.
kolserdav
14.02.2025 06:34Вот в тему статьи, когда блокировали докер хаб, я написал проект по созданию контейнеров из scratch (без загрузки готовых образов). Там две OS на самом гитхабе собирается, с разными версиями под разные архитектуры, держите для справки https://github.com/kolserdav/docker-container
Dimon41
Спасибо, очень хорошо обесняется контейниризация и её отличия от виртуализации.