Nix: идея

Приветствую!

Как вы знаете, не так давно я стал частью DevOps-команды в компании Zalora. И одним из основных наших инструментов является NixOS. А построен сей дистрибутив на основе Nix, про который многие из вас уже знают, но, уверен, далеко не все. А инструмент, надо сказать, прелюбопытнейший. И поскольку по долгу работы мне всё равно нужно с ним глубоко разобраться, разделю этот опыт с вами.

В качестве введения я избрал мой вольный перевод прекрасной статьи. Она повествует о фундаментальной идее, лежащей в основе Nix. Статья не столь свежа (датируется мартом 2014), но ничего.


Я собирался написать эту статью уже давно. Нет, я не хочу начинать очередной холивар, но ещё больше я не хочу, чтобы Linux-сообщество продолжало своё развитие, используя решения 30-летней давности. Да, речь пойдёт об инструментах управления пакетами и о связанных с этим инструментами проблемах.

История Linux-автоматизации

Помните, как было раньше? Linux-пользователь набирал в терминале какие-то команды, изменяющие состояние его системы. Эти команды останавливали некий процесс, изменяли какой-нибудь конфигурационный файл, а затем стартовали процесс заново. Затем пользователь как-то подключался к машине и убеждался, что её состояние изменено правильно. Давайте назовём этот метод Императивным Конфигурированием.

Вскоре, однако, пользователи осознали, что делать всё это ручками весьма скучно, и было бы неплохо это дело автоматизировать. И начали они писать bash-скрипты, много bash-скриптов. Однако те дни остались в прошлом. Сегодня мы обладаем куда более продвинутыми инструментами: они позволяют нам изменять Императивные шаги, изменяющие нашу систему, и использовать их повторно. Назовём этот метод Автоматизированным Императивным Конфигурированием (сей метод воплощён в таких инструментах, как Chef (Ruby) или Fabric (Python)).

Однако возрастающая сложность программных система вскоре заставила наших Linux-пользователей задуматься ещё раз. Что если бы они смогли построить некий слой абстракции над всеми этими скучными шагами и просто описать конечное состояние системы, которое они хотели бы видеть на машине? Ну, чтобы всё остальное (конкретные системные команды) сделалось где-то там, под капотом, автоматически. Что-то типа SQL, где мы сфокусированы на том, ЧТО, а не на том, КАК. Что ж, неплохая идея. Назовём такой метод Декларативным Конфигурированием. Он нашёл своё отражение в таких инструментах, как Puppet и Ansible.

Следующий уровень Декларативного Конфигурирования

И всё бы хорошо, если бы не одна проблема. Есть в этом новом слое абстракции большое-пребольшое неудобство: ещё большая программная сложность оказалось скрыта под Декларативным Конфигурированием. Позвольте объяснить.

Возьмём простое декларативное утверждение:

Package named nginx should be installed on the system.

Наш инструмент должен выполнить три шага:

  1. Понять, какой менеджер пакетов используется в нашем Linux-дистрибутиве.
  2. Проверить, не установлен ли уже такой пакет.
  3. Если нет - установить его.

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

Простой пример, показанный выше, не может продемонстрировать, сколь стремительно растёт число комбинаций состояний машины, на которой имеется довольно большое количество разных сервисов. И любой, кто писал Puppet/Chef рецепты, подтвердит вам сей феномен на практике.

Избавляемся от Состояния

Но что если бы мы смогли избавить наше Декларативное Конфигурирование от Состояния? Ведь это радикально снизило бы уровень сложности системы, уменьшило бы количество кода, а заодно и позволило бы нам гораздо проще объяснить, как написаны те или иные компоненты. И прежде чем начать разбираться в деталях, рассмотрим другой реальный пример. Вот наша задача:

Securely verify email address of a user registered on a website.

Решение с Состоянием

  1. Генерируем некий ключ (криптографически случайный, чтобы его нельзя было угадать).
  2. Сохраняем этот ключ в нашей БД.
  3. Отправляем пользователю письмо со ссылкой, содержащей этот ключ.
  4. Как только пользователь переходит по этой ссылке - проверяем совпадение этого ключа с тем, который был сохранён в нашей БД. Если всё ок, значит пользователь реально получил наше письмо, подверждаем его почтовый адрес.
  5. Удаляем тот ключ из БД.

Решение без Состояния

  1. Берём email-адрес пользователя и криптографически подписываем его.
  2. Отправляем пользователю письмо со ссылкой, содержащей эту подпись.
  3. Как только пользователь переходит по этой ссылке - проверяем подпись, и если всё ок, мы подтверждаем его почтовый адрес.

Подобное решение не требует наличия БД, ведь никакого состояния сохранять не нужно. В действительности состояние передаётся по сети, поэтому можно либо сказать, что сеть и есть наша БД, либо сказать, что никакого состояния нет вовсе.

В итоге мы имеем меньше работы (не нужно проектировать БД-схему и писать дополнительный код) и меньше ситуаций, в которые можно упереться лбом (например, миграция БД).

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

Если вас заинтересовала эта тема - рекомендую статью Сеть - моя любимая БД.

Де-факто стандарт управления пакетами без Состояния

Вернёмся к нашим баранам идее управления пакетами и конфигурирования на Linux-машинах (да и на любой другой ОС) без состояния. Назовём этот метод Декларативным Конфигурированием без Состояния.

Конфигурирование без Состояния является результатом хорошо продуманного дизайна системы (как мы убедились выше на примере проверки почтового адреса). Я не собираюсь объяснять, как спроектировать целую ОС без Состояния, ведь Eelco Dolstra (с двумя соавторами) уже сделали это для нас, и мы уже сегодня можем использовать такой Linux-дистрибутив, со всеми преимуществами дизайна без Состояния. Вместо этого я лишь частично изложу заложенную в NixOS идею.

Налейте себе чайку/кофейку, расслабьтесь и читайте дальше.

Если мы задумаемся о том, как именно пакеты устанавливаются внутри Linux-дистрибутива, это можно отразить следующей схемой:

Заметьте, что выходные файлы одного пакета являются входными файлами для другого пакета.

Это и есть наше Состояние. Файловая система является нашей простой, иерархической базой данных, которая делает подобное решение сложным и недетерминированным.

/usr можно представить себе как таблицу в нашей БД, а пакеты ищутся по имени файла внутри всяких /usr/lib, /usr/include и так далее. Разумеется, содержимое внутри этих каталогов будет изменяться в зависимости от результатов установки тех или иных пакетов. А что ещё хуже, системы сборки будут смотреть и в другие каталоги, такие как /usr/local/ или /opt, дабы обнаружить те или иные файлы, необходимые нашим пакетам. И если вы всё ещё не верите, что подобный метод вскоре выйдет из-под нашего контроля, взгляните на это.

Пакеты без Состояния

Если убрать файловую систему как Состояние, мы получим следующую схему:

Смотрите, мы изменили одну-единственную вещь:

Выходные файлы, явившиеся результатом успешной сборки пакета, сохраняются в каталоге с уникальным именем $PREFIX, поэтому, коль скоро мы можем сопоставить имя пакета (из метаданных последнего) с его $PREFIX, мы можем совершенно точно сказать, что, например, заголовочные файлы в $PREFIX/include принадлежат конкретному пакету и никому более.

Никаких больше глобальных префиксов типа /usr, /usr/local или /opt из FHS. Программное обеспечение, которое не делает шибко много предположений о своих установочных ФС-префиксах, будет просто работать.

На самом деле, другие системы сборки тоже работают с уникальными $PREFIX-ами. Просто определите такие переменные, как C_INCLUDE_PATH=$GCC_PREFIX/include:$MYLIB_PREFIX/include или LD_LIBRARY_PATH=$GCC_PREFIX/lib:$MYLIB_PREFIX/lib - и вперёд.

По ту сторону дизайна без Состояния

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

Наш пакетный менеджер реализован в виде чисто-функционального языка программирования.

Чистой называют функцию без побочных эффектов: результат её работы всецело определяется только её входными данными (а если это не так - значит, такой язык реализован неправильно). Например, функция random() не является чистой, ведь она не принимает никаких значений на вход, да и возвращает каждый раз различные значения.

Выбор чисто-функционального языка позволит нам быть уверенными в том, что ничто не сможет добавить какое-либо Состояние в наши пакетно-сборочные инструкции.

Наш пакетный менеджер называется Nix. Nix изо всех сил старается быть детерминированным. Он сохраняет все собранные пакеты в каталогах с уникальными именами и монтирует их в read-only-режиме, и это гарантирует, что ничто не изменит результат сборки после её завершения. [Прим. переводчика: пакеты воспринимаются как значения в Haskell-программе, которые, будучи однажды созданными, уже не могут быть изменены.]

Даже временнЫе метки внутри всех файлов, расположенных в каталоге с неким префиксом, изменяются по приниципу unixtime + 1. Время это побочный эффект нашей сборки, ведь два последовательных запуска должны вернуть тот же результат. Когда пакеты собираются без побочных эффектов - это называется Чистотой.

Преимущества Чистого дизайна

  1. Детерминированность. Мы убрали Состояние файловой системы, что обеспечило нам значительно бОльшую определённость. Сборки наших пакетов теперь зависят исключительно от соответствующих входных сборочных инструкций (каковы зависимости, где брать исходники и т.д).
  2. Возможность отката. Раз нет состояния - значит, мы можем “путешествовать во времени”. Просто используйте флаг --rollback или выберите подходящий конфигурационный набор в GRUB.
  3. Нет больше ада зависимостей. Пакеты всегда сохраняются по уникальными путям на основе $PREFIX, что означает возможность беспроблемного существования двух пакетов, зависящих от двух разных версий openssl.
  4. Лучшее из двух миров, мира Исходников и мира Бинарников. Поскольку мы хэшируем все наши входные данные для сборочных процессов, мы можем уникально идентифицировать каждую из сборок. Перед тем, как собирать из исходников, мы можем спросить нашу сборочную систему предоставить нам бинарный пакет на основе хэша. Если совпадений нет - собираем из исходников.
  5. Множество окружений. Для формирования рабочего bash-окружения, в котором будет доступны все наши инструменты, мы должны объединить их через символьные ссылки. Следовательно, при желании мы можем создавать различные окружения с разными наборами доступных программ.
  6. Множество пользователей. Помимо системого окружения, у каждого пользователя теперь есть своё собственное окружение для установки своего программного обеспечения.
  7. Атомарность. Ваша система активизируется лишь после того, как итоговая символьная ссылка будет указывать на ваше системное окружение. Символьная ссылка - это атомарная операция в Linux. Никаких больше проблем с поломанным состоянием из-за сбоя питания компьютера.
  8. Сборочное хозяйство. Собирайте бинарные пакеты на основе изменений в git-репозитории, запускайте тесты и деплойте.
  9. Обеспечение (sysops ready). Берите EC2, Hetzner или VirtualBox и конфигурируйте их.

Часто разработчики говорят, мол, менеджеры пакетов, который мы в настоящий момент используем, вполне могут быть адаптированы под данную философию. Да, теоретически это так, однако для этого эти менеджеры должны быть полностью перепроектированы, а с учётом требования сохранения обратной совместимости это чрезвычайно сложная задача (если вообще реализуемая).

Декларативное Конфигурирование без Состояния

Идея умного дизайна в отношении управления пакетами может быть легко распространена и на конфигурирование. Ведь конфигурационные файлы, по своей сути, это тоже пакеты, просто очень простые. Поэтому те самые входные сборочные инструкции вполне могут генерировать и конфигурационные файлы. Вот пример таких инструкций, написанных на языке Nix, для systemd сервиса nginx:

systemd.services.nginx = {
  description = "Nginx Web Server";
  after = [ "network.target" ];
  wantedBy = [ "multi-user.target" ];
  path = [ nginx ];
  preStart =
    ''
    mkdir -p ${cfg.stateDir}/logs
    chown -R ${cfg.user}:${cfg.group} ${cfg.stateDir}
    '';
  serviceConfig = {
    ExecStart = "${cfg.package}/bin/nginx -c ${configFile};
  };
};

Входные данные:

  • cfg.stateDir: Здесь nginx должен хранить состояние.
  • cfg.user: Пользователь, от имени которого должен быть запущен nginx.
  • cfg.group: Группа, под которой должен быть запущен nginx.
  • cfg.package: Пакет nginx, который следует использовать.
  • configFile: Файл nginx.conf, сгенерированный на основе cfg.appendConfig.

Если какие-либо из этих входных значений изменятся, наш nginx будет перезапущен. Никакого дополнительного кода писать не нужно.

Пример: конфигурируем наш nginx так, чтобы он был собран с поддержкой rtmp:

services.nginx.package = pkgs.nginx.override { rtmp = true; };

Входные инструкции для пакета nginx изменились, поэтому он будет перекомпилирован. $PREFIX-путь к пакету nginx, возвращённый функцией pkgs.nginx.override, изменится, и вместо чего-то такого:

/nix/store/87428fc522803d31065e7bce3cf03fe475096631e5e07bbd7a0fde60c4cf25c7-nginx-1.4.5

будет что-то эдакое:

/nix/store/0263829989b6fd954f72baaf2fc64bc2e2f01d692d4de72986ea808f6e99813f-nginx-1.4.5

А поскольку ${cfg.package} будет содержать уже новый путь к nginx-пакету, изменится и значение входного параметра для systemd сервиса.

Впечатлены? Попробуйте NixOS.

А как же Docker?

Да, Docker тоже пытается достичь детерминированности, но как? Через изоляцию вашего сервиса в своём мирке, с Императивными шагами внутри этого мирка.

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

Но, кстати, Docker умеет работать совместно с Nix. [Прим. переводчика: проект, возможно, заброшен…]

Интересно, увидим ли мы другие дистрибутивы без Состояния?..

PS. Смотрите моё выступление о NixOS на конференции FOSDEM 2014. А вот и слайды.


Вот такая статья. Ждите новых. ;-)

Кстати, есть любопытный блог с целым циклом заметок, посвящённых Nix.

ВАЖНО!

Прошу вас, друзья, не оставляйте комментариев типа: “Да кому нужен этот Nix, вот есть инструмент T, который в сто раз круче!” Ни автор оригинальной статьи, ни тем более я не желаем холиварить. Точка.

И ещё. Не спрашивайте меня о том, “а как в этом Nix сделать то-то?” Я только-только начал изучение этого инструмента, и знаю о нём ещё очень немного. По мере изучения будут выходить новые статьи, более практичные.