Перевели для вас статью про то, как с нуля создать 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
Сам контейнер будет использовать -1/merged
в качестве корневой директории: container
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-пространство имён, у должен быть ID = 1. Проверим из контейнера: bash
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-1/merged
, позволяющий избежать breakout-эксплойтов. Безопасность — не моя специализация, поэтому приведу containerссылку на статью, в которой объясняется, как эти эксплойты работают и как 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 больше не работают.
Но поскольку мы находимся в директории -1/merged
, а её файловая система основана на alpine-minirootfs, в директории containerbin
есть основные утилиты.
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
Чтобы проверить, что соблюдаются лимиты контейнера по памяти, воспользуемся устройством . Команда zero
tail
будет читать из него нулевые байты в буфер в памяти. Вскоре тот превысит лимит в 500 MiB и контроллер памяти cgroup убьёт процесс.
/ # tail /dev/zero
Killed
Теперь можно выходить из контейнера. Уберём за собой, отмонтировав корневую директорию -1/merged
: container
michal@michal-lg:/tmp/container-1$ sudo umount merged
На этом всё! Мы с нуля создали контейнер в терминале.
Заключение
Основной вывод — в контейнерах нет ничего волшебного. Это не виртуальные машины, а всего лишь результат применения крутой изоляции процессов, встроенной в ядро Linux. Изоляция достигается с помощью cgroups и пространств имён.
Полный список команд можно посмотреть в моём репозитории на GitHub.
Надеюсь, вы узнали что-нибудь новое. Если так, подпишитесь. А ещё я всегда рад пообщаться с читателями на LinkedIn и BlueSky.
P. S.
Читайте также в нашем блоге:
Автор: kubelet