Как собрать Linux-контейнер с нуля и без Docker

в 5:30, , рубрики: cgroups, containers, docker, linux, linux-контейнеры, namespaces, overlayfs, root, контейнеризация, контейнеры

Перевели для вас статью про то, как с нуля создать 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.

Читайте также в нашем блоге:

Автор: kubelet

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js