🐧 Проектирование контейнеров (часть 1): почему важно понимать разницу между пространствами ядра и пользователя?
Возможно вам приходилось разрабатывать инфраструктуру приложений на основе контейнеров. Если так, вы почти наверняка понимаете достоинства, которые контейнеры обеспечивают разработчикам, архитекторам и команде эксплуатации.
Вы наверное уже много читали о контейнерах и вам не терпится изучить технологию в деталях. Однако, перед тем как углубиться в рассуждения об архитектуре и развертывании контейнеров в производственном окружении, есть три важные вещи, которые разработчики, архитекторы и системные администраторы должны знать:
- Все приложения, включая контейнеры, опираются на ядро операционной системы.
- Ядро обеспечивает API (интерфейс прикладного программирования) для этих приложений через системные вызовы (system calls).
- Управление версиями API важно, так как они обеспечивают прямую связь между пространствами ядра и пользователя.
Все процессы осуществляют системные вызовы:
Так как контейнеры являются процессами, они тоже совершают системные вызовы:
Хорошо, мы разобрались с процессами и поняли, что контейнер – тоже процесс. Но что насчет файлов и программ внутри образа контейнера? Эти файлы и программы составляют так называемое пространство пользователя. Когда запускается контейнер, программа загружается в память из образа контейнера. После запуска программы в контейнере, она выполняет системные вызовы в пространство ядра. Возможность пространства пользователя взаимодействовать с ядром имеет критическое значение.
Пространство пользователя
- Пользовательские приложения могут включать программы, написанные на C, Java, Python, Ruby и других языках. В мире контейнеров эти программы обычно доставляются в формате образа докер (docker).
Когда вы извлекаете образ контейнера RHEL7 (Red Hat Enterprise Linux 7) из официального докер-репозитория Red Hat, вы используете минимальное предустановленное пространство пользователя. Туда входят базовые утилиты, такие как bash, awk, grep и yum (чтобы вы могли установить остальной софт).
Все пользовательские программы (в контейнерах или нет) работают, управляя данными, но где эти данные находятся? Они могут поступать из регистров центрального процессора и внешних устройств, но чаще всего они хранятся в памяти и на диске. Пользовательские программы получают доступ к данным, через специальные запросы к ядру, которые называются системными вызовами. Например аллоцирование памяти или открытие файла. В памяти и файлах часто содержится конфиденциальная информация, принадлежащая разным пользователям. Так что доступ должен запрашиваться у ядра через «syscall».
Пространство ядра
open()
обычно используется для получения файлового дескриптора в Python, C, Ruby и других языках. Вам не понравится, если программы смогут вносить изменения в файловую систему XFS, поэтому ядро предоставляет системный вызов и обрабатывает драйвера. Этот системный вызов настолько распространен, что включен в библиотеку POSIX.Заметьте, на следующем изображении bash совершает вызов getpid()
, который запрашивает свой идентификатор процесса. Также, обратите внимание, что команда cat()
запрашивает доступ к /etc/hosts через вызов open()
. В следующей статье мы подробно разберемся, как это работает в контейнерной среде, но отметим, что часть кода находится в пространстве пользователя, а часть в ядре.
- Обычные пользовательские программы постоянно создают системные вызовы, чтобы выполнить свою задачу, например, ls, ps, top и bash.
- А вот некоторые пользовательские программы, которые почти напрямую соответствуют системным вызовам: chroot, sync, mount/umount, swapon/swapoff.
Если копнуть глубже, то вот еще примеры системных вызовов, которые совершают перечисленные программы: open, getpid, socket. Обычно эти функции вызываются через библиотеки, такие как glibc, или интерпретатор Ruby, Python, Java Virtual Machine.
Стандартная программа получает доступ к ресурсам ядра через уровни абстракции, похожие на следующую диаграмму:
Чтобы понять, какие системные вызовы есть в ядре Linux, советуем посмотреть справку man syscalls. Мы выполняем эту команду на RHEL7, но используем образ контейнера RHEL6, чтобы увидеть, какие системные вызовы были добавлены или вырезаны в прошлой версии ядра.
Обратите внимание, что некоторые системные вызовы были добавлены или удалены в разных версиях ядра. Линус Торвальдс и другие разработчики позаботились, чтобы их поведение было стабильным и прозрачно понятным. В RHEL7 (версия ядра 3.10) доступно 382 системных вызова. Время от времени появляются новые, другие устаревают – это стоит учитывать при планировании вашей контейнерной инфраструктуры и приложений в ней.
Заключение
Ключевые выводы для понимания разницы между пространством ядра и пользователя:
- Приложения содержат бизнес-логику, но зависят от системных вызовов.
- После компиляции приложения набор используемых вызовов встраивается в бинарный файл (в языках более высокого уровня – в интерпретатор или виртуальную машину).
- Контейнер не абстрагируется от необходимости пространств пользователя и ядра в использовании общего набора системных вызовов.
- В мире контейнеров, пользовательское пространство упаковывается и доставляется до разных хостов, от ноутбуков до промышленных серверов.
- В ближайшие годы это вызовет проблемы.
В следующей части цикла мы рассмотрим, как взаимодействие пространств ядра/пользователя влияет на архитектурные решения, и что можно сделать для уменьшения проблем. Третья часть будет посвящена влиянию выбора пользовательского пространства на развертывание и обслуживание приложений.