Последнее десятилетие проходило под знаком контейнеризации, появлялись десятки и сотни инструментов для управления контейнерами, создания виртуальных сетей для взаимодействия контейнеров на разных узлах кластера, появлялись системы маршрутизации трафика, мониторинга доступности и иные DevOps-инструменты, которые во многом трансформировали деятельность системных администраторов вплоть до того, что начали появляться сомнения в потребности организаций в администраторах Linux (особенно с учетом появления облачных провайдеров, предоставляющих как среды для запуска контейнеров в Kubernetes, так и полностью настроенные и управляемые сервисы баз данных, очередей, систем для накопления и анализа логов и т.п.). Но нередко возникают ситуации, когда нужно осуществить некоторый уровень изоляции или ограничения уже запущенных процессов и для этого можно использовать возможности control groups ядра Linux, которые также лежат в основе технологий контейнеризации Docker и LXC. В этой статье мы последовательно разберем возможности cgroups, обсудим отличия новой cgroups v2 и затронем тему контейнеризации на основе Linux Containers. Да пребудет с вами безопасное окружение выполнения.

Идея разделения процессов и ресурсов впервые возникла еще во времена мейнфреймов, которые изначально были ориентированы на многопользовательский конкурентный доступ в режиме пакетной обработки или в интерактивном режиме как тонкого клиента (терминала). В POSIX-совместимых операционных системах процессы обладали собственным объемом памяти, своим набором файловых дискрипторов (включая стандартные каналы для ввода-вывода информации на терминал) и запускались с правами пользователя, которые разграничивали доступ к файлам и устройствам для процесса. Также для обмена данными между процессами POSIX-совместимая система предлагала механизмы межпроцессного взаимодействия (IPC), а также способы создания вспомогательных процессов и получения от них результата выполнения. Но в реальном использовании эти механизмы успешно способствовали защите данных, но не исключали ситуацию захвата ресурсов (памяти, процессорного времени, сети или дисковой подсистемы) одним приложением, что осложняло работу других пользователей на том же оборудовании. Конечно, POSIX предлагал методы управление приоритетом выполнения заданий (nice), а также лимиты на файловые дескрипторы, стек и выделяемую память, но это не давало необходимой гибкости в распределении системных ресурсов между различными процессами. Кроме того, несмотря на поддержку изоляции файловой системы (механизм jail во FreeBSD или chroot в POSIX-совместимых системах), пространство процессов, IPC и сетевой стек был для всех пользователей единым и это значительно снижало безопасность системы в целом. Одним из решений стала идея пространств имен (namespaces) в Plan 9, которая предлагала делать различное отображение системных ресурсов для разных приложений, которая в дальнейшем получило развитие в Linux Namespaces о котором мы будет говорить далее.

Отчасти эта проблема перестала быть актуальной при появлении персональных компьютеров, но была значима для совместного запуска приложений на сервере. Основная сложность была в необходимости контроля использования ресурсов и изоляции процессов со стороны ядра операционной системы. Некоторые идеи были реализованы в операционных системах реального времени (например, RSX-11M), стали появляться коммерческие реализации механизмов изоляции и ограничения ресурсов (Solaris Zones), но основной акцент был в дальнейшем сделан в сторону аппаратной виртуализации (благодаря возможностям процессоров Intel 486 и более новых), что значительно увеличивало накладные расходы из-за наличия гипервизора, запуска изолированных экземпляров операционной системы, нескольких уровней управления доступом к устройствам ввода-вывода и к оперативной памяти. Одним из решений стало использование паравиртуализации, когда гостевые операционные системы знают о существовании гипервизора и напрямую взаимодействуют с ним (это в большинстве случаев применимо только для одного семейства операционных систем).

Одним из важных шагов в гибком управлении распределением ресурсов стала реализация групп управления (control groups), абстракции для соотнесения процессов с виртуальными группами, каждая из которых могла иметь собственные ограничения на процессорное время, оперативную память, подсистему ввода-вывода, а также свой набор правил доступа к символьным и блочным устройствам системы (включая устройства хранения). Но cgroups не обеспечивают изоляцию ресурсов, поэтому вначале мы рассмотрим возможности поддержки пространств именования, которые появились в Linux Kernel 2.4.19. В Linux в настоящее время Первый и наиболее важный аспект изоляции - поддержка пространств имен.доступны 8 пространств именования объектов:

  • mnt - точки монтирования и файловые системы;

  • pid - идентификаторы запущенных процессов (и информация о них);

  • net - сетевой стек (доступные интерфейсы, возможность получения и отправки информации, создания сокетов);

  • ipc - межпроцессное взаимодействие (доступные каналы);

  • uts - идентификация системы в сети (в частности - имя хоста);

  • user - идентификаторы пользователей и групп (используются для контроля доступа к ресурсам);

  • cgroup - трансляция групп управления ресурсами внутрь изолированного окружения (чтобы исключить возможность просмотра и изменения групп для других процессов);

  • time - трансляция виртуального времени для процесса.

Пространства имен для процесса отображаются в /proc/<pid>/ns и являются символическими ссылками на виртуальные объекты (например 'ipc:[4026531839]'). По умолчанию создание процесса клонирует namespace родительского объекта, но при клонировании можно указать какие namespace будут сохраняться, но можно использовать системные вызовы unshare (для создания изолированных namespace) и setns для связывания с существующим namespace-узлом. Также можно использовать утилиту unshare (из пакета util-linux) для замены пространств имен.

Для проверки скомпилируем простое приложение на Go:

package main

import (
  "fmt"
  "os"
)

func main() {
  fmt.Println("Hello, isolated world!")
  name, err := os.Hostname()
  if err != nil {
    panic(err)
  }
  dir, err2 := os.Getwd()
  if err2 != nil {
    panic(err2)
  }
  fmt.Println("Hostname: ", name)
  fmt.Println("Current dir: ", dir)
  fmt.Println("PID: ", os.Getpid())
}

Запустим приложение сначала без использования изоляции:

linux /opt/test/rootfs # ./main 
Hello, isolated world!
Hostname:  linux.local
Current dir:  /opt/test/rootfs
PID:  10566

Заменим теперь корневой каталог для процесса (аналог chroot):

linux /opt/test/rootfs # unshare -R `pwd` /main
Hello, isolated world!
Hostname:  linux.local
Current dir:  /
PID:  10614

Для замены пространства имен PID требуется явно указать создание форка:

linux /opt/test/rootfs # unshare -R `pwd` -p -f /main
Hello, isolated world!
Hostname:  linux.local
Current dir:  /
PID:  1

Чтобы заменить имя хоста мы будем использовать hostname для конфигурирования связанного пространства имен и nsenter для запуска приложения в этом пространстве имен:

linux /opt/test/rootfs # unshare --uts=/tmp/uts hostname isolated.local
linux /opt/test/rootfs # nsenter --uts=/tmp/uts unshare -R /opt/test/rootfs -p -f /main
Hello, isolated world!
Hostname:  isolated.local
Current dir:  /
PID:  1

Аналогично может быть выполнена изоляция точек монтирования (-m), IPC (-i), сетевых интерфейсов и маршрутов (-n), идентификаторов пользователей (-u), описаний управляющих групп (-c), виртуального времени (-t).

Теперь, когда мы можем запускать процессы изолированно, нужно посмотреть на возможности ограничения ресурсов. Первоначальная модель cgroups появилась в Linux Kernel 2.6.24 и была основана на применении контроллеров, каждый из которых может ограничивать использовании ресурсов (cpu - разделение процессорного времени, cpuset - привязка процессов к ядрам, memory - использование памяти, devices - доступ к устройствам, freezer - приостановка процессов, net_cls - настройки сетевого трафика, net_prio - настройки приоритетов сетевого трафика, blkio - ограничения ввода-вывода, hugetlb - настройки huge pages, pids - ограничение количества процессов, rdma - ограничение по каналам прямого доступа к памяти), а также возвращать текущие метрики ресурсов (cpuacct - учет использования процессорного времени, perf_event - производительность в группе). Группы создаются в каталоге /sys/fs/cgroup/<type>/<id>, процессы привязываются к группе через узел tasks (сохраняются PID процессов), конфигурация определяется типом (например, максимальный объем памяти для группы test может быть определен через изменение файла):

echo 1048576 >/sys/fs/cgroup/memory/test/limit_in_bytes

Также на уровне cgroups может быть запрещено или разрешено выполнение действий с устройствами (символьными или блочными). Например, мы можем запретить запись в /dev/null, для этого получим идентификатор устройства:

ls -l /dev/null
crw-rw-rw- 1 root root 1, 3 июн 28 16:06 /dev/null

И запретим операции записи через контроллер devices для группы test (w - запись, r - чтение, m - создание нового узла для этого типа устройства через mknod):

echo "c 1:3 wm" >/sys/fs/cgroup/devices/test/devices.deny

Несмотря на значительное улучшения возможностей настройки использования ресурсов, cgroups не лишен недостатков. Один из них непосредственно следует из того, что каждый контроллер содержит свою иерархию групп (и, как следствие, при необходимости создания или изменения группы нужно отслеживать конфигурации во всех контроллерах). Вторая проблема связана с наследованием настроек на вложенные группы (для этого используется конфигурация clone_children в группе) и неоднозначностью применения ограничительных политик. Развитием cgroups стала реализация cgroup2, которая для совместимости с ранее разработанными приложениями (включая kubelet от kubernetes и docker) предполагает монтирование в специальный каталог /sys/fs/cgroup/unified и содержит в себе непосредственно иерархию групп, в которой совмещаются узлы для доступа к статистике и к установке ограничений для всех подключенных контроллеров. Также может быть настроен режим с использованием только cgroup2, тогда она монтируется в /sys/fs/cgroup.

В Gentoo (и системах на основе OpenRC) режим определяется в файле конфигурации /etc/rc.conf в параметре rc_cgroup_mode и может принимать следующие значения:

  • legacy - используется только cgroups v1 (монтируется в /sys/fs/cgroup, unified не подключается);

  • hybrid - доступны и cgroups v1 и cgroups v2 (при этом cgroups v2 поддерживает для группы только те контроллеры, которые не определены в cgroups v1);

  • unified - доступна только cgroups v2 с точкой монтирования /sys/fs/cgroup.

Также в rc.conf может быть перечислен список контроллеров, которые будут использоваться для cgroups v2 (в этом случае в cgroups v1 они не будут создаваться в дереве). Cgroups v2 поддерживаются Docker с версии 20.10, Kubernetes с версии 1.22 (как alpha-feature), Containerd с версии 1.4).

Для систем на основе systemd разрешить cgroups v2 можно через добавление опции systemd.unified_cgroup_hierarchy=1 к параметрам ядра.

Кроме упорядочивания ресурсов (теперь вся конфигурация ограничений для группы расположена в одном каталоге) появились новые метрики, связанные с памятью, что позволяет ограничить область действия out-of-memory (OOM) killer только одной группой. Например, можем провести следующий эксперимент с ограничением максимального выделения памяти (используется модель unified):

mkdir /sys/fs/cgroup/test
cd /sys/fs/cgroup/test
cd $$ >cgroup.procs
echo "1048576" >memory.max
dd if=/dev/zero of=/dev/null bs=2M

Выполнение последней команды будет остановлено из-за превышения лимита памяти при выделении буфера для команды dd. Если посмотреть на список доступных узлов для конфигурации группы (или получения текущих значений замеров), можно обнаружить некоторую унификацию по сравнению с cgroups v1:

linux /sys/fs/cgroup/lm_sensors # ls
cgroup.controllers      hugetlb.1GB.current       memory.events.local
cgroup.events           hugetlb.1GB.events        memory.high
cgroup.freeze           hugetlb.1GB.events.local  memory.low
cgroup.kill             hugetlb.1GB.max           memory.max
cgroup.max.depth        hugetlb.1GB.rsvd.current  memory.min
cgroup.max.descendants  hugetlb.1GB.rsvd.max      memory.numa_stat
cgroup.procs            hugetlb.2MB.current       memory.oom.group
cgroup.stat             hugetlb.2MB.events        memory.pressure
cgroup.subtree_control  hugetlb.2MB.events.local  memory.stat
cgroup.threads          hugetlb.2MB.max           memory.swap.current
cgroup.type             hugetlb.2MB.rsvd.current  memory.swap.events
cpu.idle                hugetlb.2MB.rsvd.max      memory.swap.high
cpu.max                 io.max                    memory.swap.max
cpu.max.burst           io.pressure               pids.current
cpu.pressure            io.stat                   pids.events
cpu.stat                io.weight                 pids.max
cpu.weight              memory.current            rdma.current
cpu.weight.nice         memory.events             rdma.max

Файлы .stat представляют текущие замеры от соответствующего контроллера, max - ограничение максимально допустимого значения, pressure - замеры длительного превышения рекомендуемого максимума (high), events - количество событий по срабатыванию контроллера (например, вызовов агрессивной очистки памяти при превышении memory.high). Для относительного распределения ресурсов используется конфигурация веса (weight, по умолчанию 100). Список доступных контроллеров может быть прочитан из cgroup.controllers, присоединение процесса к группе выполняется через файл cgroup.procs. Общая статистика группы может быть извлечена из cgroup.stat.

Совместное использование возможностей namespace (например, через unshare или clone) и cgroups (с присоединением идентификатора запущенного процесса) позволит реализовать полный контроль над запущенным процессом как с точки зрения доступных для него системных объектов и каталогов системы хранения, так и для контроля используемых ресурсов оборудования (процессора, памяти, ввода-вывода и др.). В принципе этих знаний достаточно для реализации изолированного выполнения процессов без необходимости установки движков системы контейнеризации (CRI-O, Containerd, Docker), но все же кратко рассмотрим возможности библиотеки liblxc (Linux Containers), построенной поверх рассмотренных ранее технологий и обозначим возможные варианты ее использования.

Прежде всего отметим, что архитектурно Linux Containers похож на Docker и реализован в виде процесса-демона (lxd) и утилиты командной строки (lxc). lxd реализует API и позволяет выполнить первоначальную настройку (lxd init). Во время настройки будут заданы уточняющие вопросы о режиме кластеризации, создании нового пула для хранения (из него будут подключаться каталоги в изолированное окружение), настройки сетевого моста (для изоляции внутреннего трафика и управляемой публикации портов во внешнюю сеть). Также можно настроить установку управляющего процесса на физическую машину (через свободный инструмент MAAS). Конфигурация хранилища и сетевого трафика реализуется в виде профиля, который также объединяет доступ к виртуальным устройствам (список может быть просмотрен через команду lxc profile device show default, здесь default - название профиля по умолчанию). Типы поддерживаемых устройств позволяют подключать диски (type: disk), сетевые устройства (type: nic), сокеты (type: proxy), графический адаптер (type: gpu). Создадим новый профиль для запуска графического приложения (опубликуем сокет для взаимодействия с X-Server):

lxc profile create x11
lxc profile device add x11 video proxy bind=container \
    connect=unix:@/tmp/.X11-unix/X1 listen=unix:@/tmp/.X11-unix/X0\
    security.gid="1000" security.uid="1000"
lxc profile set x11 environment.DISPLAY=0

Образы изолированных файловых систем (контейнеров) могут быть получены из сетевых репозиториев (lxc remote list) или импортированы из локального описания (файл метаданных + каталог или архив с файловой системой). Образ используется для создания экземпляра, к которому может быть присоединен один или несколько профилей.

Сначала запустим готовый образ ubuntu:22.04 и привяжем к нему подключение к X11-серверу для запуска графических приложений:

lxc launch ubuntu:22.04 ubuntu -p default -p x11

После успешного запуска можно перейти в консоль контейнера (или непосредственно выполнить команду):

lxc exec ubuntu bash

При запуске для подключения к X11 будет использоваться прокси-файл /tmp/.X11-unix/0 для подключения к X-серверу хост-системы.

Аналогично рассмотренному сценарию можно создать собственный образ из любого каталога файловой системы, который содержит все необходимые зависимости для выполнения кода. Возьмем в качестве примера ранее разработанное приложение на Go (для его выполнения не требуются внешние зависимости) и подготовим новый образ для LXC.

Создадим структуру каталогов:

metadata.yaml - описание образа
rootfs - корневой каталог будущего образа
templates - шаблоны для генерации файлов с использованием параметров экземпляра

metadata.yaml минимально должен содержать два ключа: architecture (например, x86_64, список архитектур можно посмотреть здесь) и creation_date (unix timestamp в секундах), но может дополнительно определять правила создания файлов при генерации экземпляра (здесь могут использоваться шаблоны из templates). Используем lxc image import для создания образа на основе созданной заготовки:

lxc image import /opt/test --alias test

Alias определяет название образа. Проверим доступность образа в локальном репозитории:

+-----------+--------------+--------+---------------------------------------------+-------------+-----------+----------+------------------------------+
| ПСЕВДОНИМ | FINGERPRINT  | PUBLIC |                 DESCRIPTION                 | АРХИТЕКТУРА |   TYPE    |   SIZE   |         UPLOAD DATE          |
+-----------+--------------+--------+---------------------------------------------+-------------+-----------+----------+------------------------------+
| test      | 5b8a6300e0fe | no     |                                             | x86_64      | CONTAINER | 0.97MB   | Jun 28, 2022 at 9:07pm (UTC) |

Но при попытке запуска мы получим ошибку No such file or directory - Failed to exec "/sbin/init". Это связано с тем, что LXC ожидает получить полностью функциональный образ операционной системы, который может быть запущен в режиме виртуальной машины (lxc launch ubuntu:22.04 --vm), как следствие нужно или собрать базовый образ на основе любого дистрибутива Linux (для этого можно использовать distrobuilder, который создает образ на основе указанного корневого образа или с использованием установщика. Например, для создания образа на основе Alpine Linux можно использовать следующий файл конфигурации:

image: 
  description: My Alpine Linux 
  distribution: minimalalpine 
  release: 3.16.0 

source: 
  downloader: alpinelinux-http 
  url: http://dl-cdn.alpinelinux.org/alpine/ 
  keys: 
    - 0482D84022F52DF1C4E7CD43293ACD0907D9495A 
  keyserver: keyserver.ubuntu.com 

packages: 
  manager: apk
  sets:
  - packages:
    - go
    action: install

Образ может быть сгенерирован через distrobuilder build-lxd alpine.yaml. При необходимости сборки образа с конфигурацией можно использовать возможности создания файлов из шаблонов (описываются в секции files в yaml) или создать корневую файловую систему (distrobuilder build-dir) и потом собрать преднастроенный образ.

При создании экземпляра контейнера на основе образа liblxc регистрирует два cgroup: lxc.monitor.<name> для наблюдения за функционированием контейнера (связан с процессом [lxc monitor], который запускает /sbin/init и lxc.payload.<name> для выполнения процессов внутри контейнера. Кроме того, создаются отдельные группы для доступа к виртуальному хранилищу (lxcfs) и демону liblxc (lxd). Для каждого экземпляра контейнера создается свой набор namespaces, которые создаются в соответствии с присоединенными профилями.

Мы рассмотрели основные идеи изоляции процессов в ядре Linux и инструменты для управления ресурсами и подготовки изолированного окружения с преднастроенной файловой системой на основе Linux Containers. Кроме рассмотренных возможностей LXC реализует снимки и возможность их восстановления, кастомизация файлов внутри контейнера над существующим образом на основе шаблонов и параметров запуска, выполнение фоновых операций (lxc operation), копирование и перемещение экземпляров между серверами в кластере (lxc copy / lxc move), операции над файлами в запущенном контейнере (lxc file delete / push / pull / edit), контроль доступа к сети для входящего (ingress) и исходящего (egress) трафика (lxc network acl create), подключение к консоли контейнера (lxc console), аналогичное подключение к серверу по SSH. Таким образом LXC реализует полноценную изолированную среду выполнения для операционной системы без необходимости создания виртуальной машины (только на механизмах namespaces / lxc).

Статья подготовлена в преддверии старта курса Administrator Linux. Professional. По ссылке ниде вы сможете узнать подробнее о курсе и зарегистрироваться на бесплатный урок.

Комментарии (1)


  1. RomTec
    30.06.2022 02:19
    +1

    Некоторые идеи были реализованы в операционных системах реального времени (например, RSX-11M),

    Спасибо за ссылку! ушёл в ностальгию, вернусь нескоро