08/06/2023

Зачем рассказывать про контейнеризацию в 2023 году

Опытные специалисты с характерным оттенком глаз могут справедливо возмутиться, что это всё уже давным-давно разжёвано и вообще RTFM. И будут отчасти правы. Тем не менее приходят новые специалисты, которые не застали бесплатную рассылку дисков с Ubuntu и вдумчивую компиляцию ОС с нуля.

Каждая новая технология поначалу держится на энтузиастах, которые её полностью понимают. Например, первые пользователи радио знали почти всё про радиосвязь, могли на коленке собрать детекторный приёмник и ловить радио «Маяк» на металлическую вешалку и моток проводов. Первые пользователи GNU/Linux знали всё про ядро и ключевые принципы работы. По крайней мере, вариант «поправил и скомпилировал драйвера для модема, чтобы настроить сеть» был не самым редким. Текущие пользователи обычно не сталкивались с основами, так как начали щупать технологию уже после снижения порога входа.

Те же процессы идут не только в среде потребителей технологий, но и среди инженеров. С одной стороны, узкая специализация совершенно нормальна, с другой — мы рискуем получить аналог культа Галактического Духа на Анакреоне из цикла романов «Основание» Азимова. Техножрецы выполняют сложные ритуалы, ядерные реакторы пайплайны работают. Ровно до тех пор, пока всё не сломается к чертям на низком уровне, а чинить будет некому.

Так происходит и с контейнеризацией. Я всё чаще встречаю на собеседованиях devops-инженеров, которые знают, как пользоваться Docker и Podman, пишут Dockerfile, но теряются, когда спрашиваешь про namespaces, и начинают плавать при вопросе: «А зачем, чем RPM хуже?» Все собирают контейнеры, и я собираю. Таков Путь. Не всегда, кстати, оптимальный.

Меня зовут Иван, я работаю главным техническим руководителем разработки в Газпромбанке. Сегодня попробую раскрыть эту тему немного под другим углом. Поэтому пройдёмся по базису с другого ракурса:

Базис, на котором строится современная контейнеризация.
  • Отличия от виртуализации.
  • Linux namespaces и что общего у firefox с docker.
  • Nsenter — давайте закинем приложение внутрь исполняемого контейнера на низком уровне.
  • Unshare и изоляция без docker.

Наливайте себе чашку кофе и поехали.

Уровни абстракции

Уровень железа

Базис, на котором строится вся современная инфраструктура, — это железо. Как часто говорят, нет никаких облаков, есть интерфейс к такому же железу в чужом дата-центре. При этом оборудование может быть совершенно любым — китайское оборудование со своими особенностями, брендовые серверы от HP или даже самосборный кластер из Raspberry Pi с кастомными контроллерами. Научить программы взаимодействовать с этим зоопарком напрямую в теории возможно, но в 99% случаев совершенно нецелесообразно. Большинство разработчиков хотят написать код один раз и запускать его где угодно с минимальными изменениями, чтобы не задумываться, что там под капотом — SSD, HDD, лента стримера или вообще восстановленный just for fun floppy-disk. Также в идеале не хочется задумываться о доступах к файлам, областям оперативной памяти и прочих нюансах.

Системные вызовы — уровень ядра

Для этого вводится уровень абстракции, который работает посредником между hardware и software, — ядро ОС. Оно обеспечивает необходимые API-вызовы, которые позволяют взаимодействовать с теми или иными ресурсами. Их называют системными вызовами. Чаще всего они относительно низкоуровневые, надёжные и довольно примитивные. Например, «считай 10 байт, открой файл, считай 10 байт».

В целом можно упороться и писать приложения, которые используют почти исключительно системные вызовы. Представим, что нам нужно написать оконное приложение на голом ядре. Мы можем напрямую отправить в буфер видеокарты отрисованное окно приложения и вывести его на экран, отреагировать на нажатия клавиш. Потом через системные вызовы перехватывать координаты мыши и вычислять, в какой точке экрана произошёл клик, чтобы правильно отработать событие «пользователь нажал на кнопку button_1». Сурово, иногда необходимо, но обычно всё-таки не для графических приложений. Переиспользование готового кода в этом случае минимально, а нагрузка на разработчиков и требования к их квалификации очень высоки. Можно и в машинных кодах напрямую в процессор тогда тыкать, что уж там.

Уровень библиотек

Более типовой вариант состоит в использовании готовых библиотек, которые заворачивают системные вызовы в более удобные для работы абстракции и берут на себя все низкоуровневые проблемы шевеления мышью, доступа к сетевым ресурсам и звуковым устройствам, как тот же Pshsh-pshh-audio PulseAudio.

Уровень пользовательского пространства

Следующий уровень — это user-space. Сама программа ничего не знает про наше оборудование, про ОС и прочее. Обычно это просто набор высокоуровневых инструкций, которые полагаются на зависимости в виде библиотек из предыдущего слоя.

Операционная система создаёт для нашей программы отдельный изолированный процесс, который позволяет приложению не задумываться о доступных ресурсах и прочих деталях. Она работает сама по себе, искренне полагая, что исполняется в гордом одиночестве и все ресурсы принадлежат ей. На самом деле ОС в это время выдаёт этому процессу нужный объём оперативной памяти, гарантируя, что никто посторонний туда ничего внезапно не запишет, следит за выделением процессорного времени, «ставя на паузу» какое-то приложение, пока на этом ядре выполняются расчёты для чего-то другого. Всё это происходит в соответствии с квотами и приоритетами. Удобно.

Как вообще может программа, которая ничего не знает про ОС, работать со всеми этими фреймворками и обвязкой? Делается это с помощью динамически подключаемых библиотек. По сути, это набор некоторых типовых инструкций, который вынесен в отдельную абстракцию, чтобы было проще.

Давайте залезем в консоль и посмотрим, как это работает.
Для начала мы натравили команду file на бинарник ls и убедились в том, что он динамически слинкован с библиотеками. Без них работать не будет, так как полагается на их наличие в ОС. Далее с помощью ldd мы определили, какие именно библиотеки используются приложением при работе.

При этом, что важно, подключаются не просто какие попало библиотеки, а вполне конкретные их версии. Дело в том, что в каких-то минорных релизах могут править баги, что-то оптимизировать и фиксить уязвимости. Но при крупных изменениях могут добавляться новые фичи, а старые ломаться. Да, традиционно для популярных библиотек стараются максимально долго поддерживать все старые API-вызовы, но и их приходится убирать, если это мешает внедрению новых архитектурных решений. Обычно о таком предупреждают заранее, долгими и упорными сообщениями о том, что вот конкретно этот вызов deprecated и скоро всё сломается — перепишите. Как обычно, часто никто ничего не делает до последнего момента, если бизнес не хочет за это платить прямо сейчас.

Никто не любит поддержку

Поддерживать ПО тяжело. Для начала — оно уже написано и работает. Бизнес крайне не любит расставаться с деньгами, чтобы решать проблемы, которые ещё не наступили. Кроме этого, есть сложности во взаимодействии между разработчиком и конечным пользователем, который будет запускать приложение.

Например, сидит разработчик на древней Ubuntu и всё никак не обновится. Или в стародавние времена втащил куда-то в основание приложения библиотеку, которая была похоронена ещё под несколькими слоями кода и абстракций. И вот он берёт артефакт своего приложения или исходный код и отдаёт девопсу. Инженер выкатывает это приложение в среду, и всё благополучно падает. Ругается, что ног не чувствует, рук не чувствует, вон ту библиотеку не завезли, а вот эта непонятной версии, вызовы не те.

Девопс возвращается к разработчику и говорит, что ни черта не работает, нужно понять, как запустить. Но может получить ответ в духе «ничего не знаю, у меня такая же нога, и она не болит; всё работает, я проверял».

И вот тут возникает дилемма — как лучше воспроизвести окружение, которое необходимо приложению для полноценной работы? Допустим, я сейчас накидаю ворох из библиотек и как-то запущу это в тестовой среде. А как заказчику бинарник передавать? Писать огромную инструкцию о том, как настроить среду с нужными библиотеками, тащить всё огромным архивом и инсталлировать набором скриптов или найти более простое решение?
Вот на этом моменте есть разные варианты решений. Если есть много ресурсов для разработки, пишется что-то монументальное и низкоуровневое, то можно поддерживать несколько наиболее популярных дистрибутивов ОС с их наборами библиотек. То есть разработчики должны учитывать наборы библиотек, которые поставляются с определёнными версиями ОС, и тщательно тестировать в различных средах. Воткнуть новые модные фичи при заявленной поддержке CentOS 6, естественно, не получится. Плюс, кроме сопровождения этого зоопарка, добавится головной боли с закрытием уязвимостей и бэкпортированием новых фич с учётом старых версий библиотек на более ранних дистрибутивах. Ну и скорее всего, придётся тесно взаимодействовать с мейнтейнерами дистрибутивов, чтобы всё устанавливалось и собиралось, как задумано.

Дорого. Сложно. Очень удобно и прозрачно для конечного пользователя, которому будет достаточно выполнить yum install super_application. Одни и те же библиотеки используются множеством приложений и потребляют меньше памяти.

Контейнеризируем или виртуализируем

Чаще всего ресурсов на поддержку множества дистрибутивов нет. Можно ограничиться небольшим их количеством, но проблемы начнутся, когда дистрибутив станет oldstable, а небольшой команде придётся обновлять все зависимости при переходе на новую версию ОС.

В этом случае традиционным уже решением станет упаковка всего в docker image и передача заказчику. На чём именно он его запустит, уже не так важно. Благодаря слою абстракции наш образ будет содержать всё необходимое в переносимом виде. Если девопсы хорошие, то образ получится минималистичным и будет содержать только самое необходимое.

Тут внезапно выясняется, что приложению требуется работать с аппаратными токенами, контроллерами жёстких дисков и кассовыми аппаратами. Нам нужны полноценные драйверы, а засунуть своё ядро в контейнер не получится. Да и не даст нам ОС этого сделать. В этом случае поможет только полная виртуализация, где гипервизор host-OS позволит нам запустить любое ядро с драйверами, которые нам нужны. Часто это более оптимальное решение, но сейчас мы рассмотрим ситуацию, когда нам не нужен оверхед в виде множества запущенных ядер полноценных ОС для запуска небольших приложений.

В случае контейнера системные вызовы мы отправляем в ядро, на котором запущены все контейнеры, а уже библиотеки перетаскиваем вместе с бинарником внутри docker image. По сути, это позволяет нам говорить процессу: «Ты за библиотеками не туда ходи, а вот сюда. Они в контейнере лежат». При этом процесс ничего не знает о файловой системе и компьютере в целом. Всё, что он знает, он получает от ядра. Когда мы говорим: «Дай нам список файлов в директории», мы отправляем запрос в ядро. Ядро в ответ на этот системный вызов отвечает нам списком файлов директории. Мы отправляем запрос в ядро: «Сообщи, пожалуйста, список сетевых интерфейсов». Ядро отвечает списком сетевых интерфейсов. «Ядро, открой, пожалуйста, нам tcp соединение». Ядро нам возвращает указатель на это соединение. То есть процесс в принципе без ядра беспомощен, хоть и притащил с собой все нужные для приложения библиотеки.
Namespace Ядро Linux нативно поддерживает так называемые namespace. Этот механизм не требует установки каких-то дополнительных пакетов, он поставляется сразу на уровне ядра. Он позволяет нам отвечать на системные вызовы каждого приложения по-разному. По мере развития этого механизма добавлялись новые возможности по виртуализации тех или иных функций.

Namespace

Ядро Linux нативно поддерживает так называемые namespace. Этот механизм не требует установки каких-то дополнительных пакетов, он поставляется сразу на уровне ядра. Он позволяет нам отвечать на системные вызовы каждого приложения по-разному.

По мере развития этого механизма добавлялись новые возможности по виртуализации тех или иных функций.
С появлением этой технологии появилась возможность, например, отдавать разные наборы доступных сетей при запросе к ядру из приложений, которые выполняются в разных namespace. В таблице выше представлены все доступные на текущий момент пространства имён. Из них относительно недавно добавился time namespace, который позволяет разным процессам видеть разное системное время или часовой пояс.

Проверить присвоенные пространства можно так:
Давайте посмотрим на некоторые из них немного поближе.

Network namespace позволяет нам изолировать ресурсы, связанные с сетью. При этом процессы из разных пространств видят свои собственные сетевые устройства, таблицы маршрутизации, правила firewall и так далее. Если приложение выполняется в процессе с отдельным net namespace, то мы можем гарантировать полную сетевую изоляцию от всей остальной системы и взаимодействие только через специально выделенные для этого интерфейсы.

Mount namespace — это та область файловой системы, которая предоставляется приложению как корневая. То есть мы создаём какую-то структуру в контейнере и говорим процессу: «Вот эта директория — это/. Пользуйся». Проверить можно так:
Также есть namespace, который представляет новые PID, создаёт namespace PIDов, пользователей. User namespace позволяет иметь свою собственную копию пользовательских и групповых идентификаторов, IPC позволяет нам организовать межпроцессное взаимодействие, а UTS — это собственный hostname.

Где это используется?

Итак, наше ядро умеет обеспечивать нативную изоляцию процессов друг от друга при необходимости. Да, именно этот механизм лежит в основе docker-контейнеризации, но это далеко не исчерпывающий список технологий, которые используют namespaces.

Воспользуемся командой lsns и посмотрим, кто и как у нас использует этот метод изоляции:
Совершенно внезапно без всяких docker-контейнеров наш firefox использует пространства имён для более качественной изоляции от основной ОС и повышения безопасности. А ещё snap, распространённый в ubuntu-based дистрибутивах, предоставляет собственные изолированные интерфейсы своим приложениям.

Если запустить то же самое от имени root, станет видно и системные компоненты, которые используют namespace для задач изоляции:

Смотрим на Docker

Попробуем сделать то же самое, но уже с запущенным привычным docker.
И изнутри контейнера, и снаружи мы видим, что системные вызовы полностью изолированы в отдельном namespace. Все приложения внутри контейнера не заметят разницы между запуском в среде контейнера и на хост-системе за счёт этого механизма.

Давайте проверим hostname:
Несмотря на то что мы обратились с системным вызовом к общему ядру, bash-сессия внутри контейнера получила ответ, отличный от мастер-системы от uts namespace. Аналогично уникальные ответы мы получим при запросах доступных сетевых интерфейсов или текущего времени от time namespace:

nsenter и unshare

Есть прекрасная утилита nsenter, которая позволяет нам запускать процессы в произвольных namespace, просто зная их PID. Давайте попробуем ещё раз.

Запустим в одной консоли интерактивную сессию с docker, а в другой попробуем подкинуть приложение в то же пространство напрямую, не используя стандартной обёртки.
Пустой контейнер, где выполняются только два процесса
Находим pid нашего контейнера:
Теперь давайте запустим sleep внутри нашего контейнера на одну минуту:
Внутри нашего контейнера мы видим команду sleep, которая была запущена в этом изолированном namespace. Причём без классических docker-инструментов.

А можно ли запустить какой-нибудь процесс без докера, но при этом чтобы этот процесс оказался в отдельных namespace? Да, можно, конечно. Для этого есть прекрасная утилита unshare. Она как раз позволяет запускать приложения в изолированных namespace, чтобы не влиять на основную систему:
В примере выше мы создали новую bash-сессию, но в изолированном namespace, успешно поменяли hostname, а после выхода из сессии увидели, что эти изменения были применены только в рамках изолированного пространства имён.

Краткие выводы

Основной тренд сейчас заключается в максимальной модульности. Этот подход позволяет нам дёшево собрать любой велосипед со своим набором библиотек и гарантировать его запуск почти где угодно. Это позволяет резко снизить time-to-market, обеспечить переносимость, отказоустойчивость, лёгкость балансировки и прочие приятные вещи, которые даёт переход от парадигмы Pets (уникальные, вручную настроенные сервисы и серверы) к парадигме Cattle (одинаковые номерные сущности, которые поднимаются сотнями и так же без жалости убиваются).

При этом не нужно забывать, что docker — это всего лишь крайне удобная обёртка поверх стандартных механизмов ядра. И они используются не только в связке с docker, но и со многими другими технологиями.

Автор: Кушневский Иван 

Главный технический руководитель разработки в Центре экспертизы разработки и сопровождения систем.
Читай эту и другие статьи об IT в Газпромбанке  в нашем блоге на Хабре.
0%

Банк ГПБ (АО) использует файлы cookie. Подробная информация –
в правилах по обработке персональных данных. Вы можете запретить сохранение cookie в настройках своего браузера.