Воспроизводимая среда разработки с Nix

Вся моя разработка происходит внутри виртуальных машин на нескольких устройствах: иногда я использую стационарный пк, иногда ноутбук, и на каждой машине приходится настраивать одинаковое окружение. Как C++ разработчик, я часто нуждаюсь в менеджере пакетов и если раньше я полагался на FetchContent и скрипты сборки для установки зависимостей, то в этом году решил разобраться с инструментом Nix, который является и пакетным менеджером, и позволяет настраивать детерминированное окружение одной командой. В этой статье я коротко расскажу что такое Nix, что он предлагает и покажу как с его помощью я создаю окружения разработки.

Что такое Nix

Nix - это дистрибутив (NixOS) и пакетный менеджер для Linux и Mac OS. В основе Nix лежит собственный функциональный язык программирования с одноименным названием. Nix позволяет создавать воспроизводимое и детерминированное окружение в декларативном стиле, что исключает необходимость вручную устанавливать и собирать отдельные пакеты. С Nix можно быть уверенным, что на любом компьютере будет одинаковое окружение разработки, а так же что никакие сторонние зависимости не привнесут свои изменения в проект.

В пакетном менеджере Nix каждый пакет создается из рецепта - Derivation. Рецепт описывает все, что необходимо для сборки пакета: зависимости, переменные окружения, исходные файлы, и т.п. Все установленные пакеты хранятся в директории /nix/store и имеют пути /nix/store/8p4l1ih9drlfybaddajjj22x028dqn0b-z3-4.12.1, где 8p4l1ih9drl... - хэш графа зависимостей. Таким образом достигается атомарность и версионность - если изменить какие-либо зависимости пакета, хэш будет другой. У Nix доступно множество команд: nix-build, nix-env, nix-shell, в этой статье же речь пойдет только о последней.

Команда nix-shell управляет зависимостями рецепта: их загрузкой, сборкой и настройкой окружения. nix-shell можно сравнить с venv питона, только помимо зависимостей (библиотек), она позволяет устанавливать различные программы:

$ nix-shell -p stdenv
these 4 paths will be fetched (0.03 MiB download, 0.17 MiB unpacked):
  /nix/store/45a55rzx3k794626g8adslzc6557gh0j-expand-response-params
  /nix/store/bfbp3ypd9nm3fapz634gvvs738blrl0y-gcc-wrapper-12.2.0
  /nix/store/c3f4jdwzn8fm9lp72m91ffw524bakp6v-stdenv-linux
  /nix/store/i2pdyabq6nrrnisbkma71h42fw6ha0y6-binutils-wrapper-2.40
copying path '/nix/store/45a55rzx3k794626g8adslzc6557gh0j-expand-response-params' from 'https://cache.nixos.org'...
copying path '/nix/store/i2pdyabq6nrrnisbkma71h42fw6ha0y6-binutils-wrapper-2.40' from 'https://cache.nixos.org'...
copying path '/nix/store/bfbp3ypd9nm3fapz634gvvs738blrl0y-gcc-wrapper-12.2.0' from 'https://cache.nixos.org'...
copying path '/nix/store/c3f4jdwzn8fm9lp72m91ffw524bakp6v-stdenv-linux' from 'https://cache.nixos.org'...

[nix-shell:~]$

Здесь создается окружение с единственным пакетом stdenv, в который входит gcc компилятор, make и другие базовые программы. Если запустить nix-shell без аргументов, конфигурация загрузится из shell.nix.

Настройка окружения

Начнем с простого. У меня есть проект, который нужно собрать с помощью cmake и clang-15. Проект так же будет зависеть от трех библиотек: fmt, range-v3 и LLVM 15. К счастью, пакетный менеджер предлагает более 80 тыс. различных пакетов и эти библиотеки точно в нем есть. Поэтому shell.nix файл будет выглядеть следующим образом:

{ pkgs ? import <nixpkgs> {} }:
let
  stdenv = pkgs.llvmPackages_15.stdenv;
in rec {
  project = stdenv.mkDerivation {
    name = "my-project";

    nativeBuildInputs = [
      pkgs.cmake
      pkgs.ninja
    ];

    buildInputs = [
      pkgs.range-v3
      pkgs.fmt
      pkgs.llvm_15
    ];
  };
}

Здесь pkgs - коллекция пакетов (рецептов) из https://search.nixos.org/packages:

{ pkgs ? import <nixpkgs> {} }:

Стандартное окружение перезаписывается окружением из пакета llvmPackages_15. Благодаря этому сборка проекта и зависимостей будет производиться компилятором clang-15:

stdenv = pkgs.llvmPackages_15.stdenv;

В этом файле есть один рецепт под названием project. В нем переменной nativeBuildInputs описываются build-time зависимости, т.е. система сборки:

nativeBuildInputs = [
      pkgs.cmake
      pkgs.ninja
    ];

runtime зависимости т.е. динамические библиотеки описываются переменной buildInputs:

buildInputs = [
      pkgs.range-v3
      pkgs.fmt
      pkgs.llvm_15
    ];

После запуска nix-shell, все скомпилированные зависимости загрузятся и будут доступны в cmake через функцию find_package:

cmake_minimum_required(VERSION 3.15)

set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

project(my_project)

# Packages
find_package(fmt CONFIG REQUIRED)
find_package(LLVM 15.0 CONFIG REQUIRED)
find_package(range-v3 CONFIG REQUIRED)

target_link_libraries(${PROJECT_NAME} PRIVATE 
  fmt::fmt
  range-v3 
  ${llvm_libs}
) 

Сборка пакета из исходных файлов

Допустим требуется библиотека, которой нет у пакетного менеджера или пакет, который чем-то не устраивает, например z3. По какой-то непонятной причине, в пакете z3 отсутствует cmake файл из-за чего find_package его не найдет. Чтобы решить эту проблему, необходимо написать рецепт под свою версию z3 и собрать его из исходных файлов:

z3 = stdenv.mkDerivation rec {
    version = "4.12.1";
    name = "z3-${version}";

    src = pkgs.fetchurl {
      url = "https://github.com/Z3Prover/z3/archive/refs/tags/z3-4.12.1.tar.gz";
      sha256 = "sha256-o3Nfq/AOE0GtzHA5SZPAX9PirhZ6Ppu0YEXjMITrZKM=";
    };

    cmakeFlags = [
      "-DZ3_BUILD_DOCUMENTATION=OFF"
    ];

    nativeBuildInputs = [
      pkgs.cmake
    ];

    buildInputs = [
      pkgs.python39
    ];

    postPatch = "substituteInPlace z3.pc.cmake.in \
      --replace '=\$\{exec_prefix\}/' '' \
      --replace '=\$\{prefix\}/' ''";
  };

Сначала нужно указать откуда скачать исходные файлы. В Nix есть огромное кол-во способов скачать исходные файлы: fetchzip, fetchurl, fetchFromGithub, fetchFromGitlab, и т.д. fetchurl и fetchzip знают как работать с архивами, поэтому после загрузки они их еще и распакуют:

src = pkgs.fetchurl {
      url = "https://github.com/Z3Prover/z3/archive/refs/tags/z3-4.12.1.tar.gz";
      sha256 = "sha256-o3Nfq/AOE0GtzHA5SZPAX9PirhZ6Ppu0YEXjMITrZKM=";
    };

Переменной cmakeFlags можно передать дополнительный список переменных в cmake на этапе конфигурации проекта:

cmakeFlags = [
      "-DZ3_BUILD_DOCUMENTATION=OFF"
    ];

Иногда, при установке пакета, необходимо изменить исходные файлы. Для этого есть метод substituteInPlace. substituteInPlace в качестве аргументов берет имя файла и список строк под замену. В данном случае необходимо изменить пути в файле z3.pc.cmake.in, требуемый для pkg-config программы.

Содержимое z3.pc.cmake.in:

prefix=@CMAKE_INSTALL_PREFIX@
exec_prefix=@CMAKE_INSTALL_PREFIX@
libdir=${exec_prefix}/@CMAKE_INSTALL_LIBDIR@
sharedlibdir=${exec_prefix}/@CMAKE_INSTALL_LIBDIR@
includedir=${prefix}/@CMAKE_INSTALL_INCLUDEDIR@

Дело в том, что переменные prefix, exec_prefix, CMAKE_INSTALL_LIBDIR, и т.д. будут содержать полные пути директорий и из-за этого libdir, sharedlibdir и includedir будут равны /nix/store/хэш-z3//nix/store/хэш….

Хоть мы и не используем pkg-config, Nix все равно будет ругаться, что пути неверные, поэтому исходники необходимо подредактировать. Эта команда просто удалит префиксы из файла:

postPatch = "substituteInPlace z3.pc.cmake.in \
  --replace '=\$\{exec_prefix\}/' '' \
  --replace '=\$\{prefix\}/' ''";

Далее включаем библиотеку z3 в список runtime зависимостей и теперь она так же будет доступна через find_package:

buildInputs = [
      pkgs.range-v3
      pkgs.fmt
      pkgs.llvm_15
      z3
    ]

Изменение уже существующего рецепта

Бывают случаи, когда нужно обновить версию пакета или url для скачивания и чтобы не писать рецепт заново, его можно перезаписать. Например в рецепте xed от Intel, как и в случае с z3, отсутстует cmake файл. Только сборка из исходных файлов его не добавит, поэтому необходимо добавить его руками, взяв за основу уже существующий рецепт:

xed = pkgs.xed.overrideAttrs (finalAttrs: previousAttrs: {
    buildPhase = previousAttrs.buildPhase + ''
      mkdir -p $out/lib/cmake/xed/

      echo '
      set(XED_LIBRARY_DIR "''${CMAKE_CURRENT_LIST_DIR}/../../../lib")
      set(XED_LIBRARIES "''${XED_LIBRARY_DIR}/libxed''${CMAKE_STATIC_LIBRARY_SUFFIX}" "''${XED_LIBRARY_DIR}/libxed-ild''${CMAKE_STATIC_LIBRARY_SUFFIX}")
      set(XED_INCLUDE_DIRS "''${CMAKE_CURRENT_LIST_DIR}/../../../include")

      add_library(XED::Main STATIC IMPORTED)
      set_target_properties(XED::Main PROPERTIES
          IMPORTED_LOCATION "''${XED_LIBRARY_DIR}/libxed''${CMAKE_STATIC_LIBRARY_SUFFIX}"
      )

      add_library(XED::ILD STATIC IMPORTED)
      set_target_properties(XED::ILD PROPERTIES
          IMPORTED_LOCATION "''${XED_LIBRARY_DIR}/libxed-ild''${CMAKE_STATIC_LIBRARY_SUFFIX}"
      )

      add_library(XED::XED INTERFACE IMPORTED)
      set_target_properties(XED::XED PROPERTIES
          INTERFACE_INCLUDE_DIRECTORIES "''${XED_INCLUDE_DIRS}"
          INTERFACE_LINK_LIBRARIES "XED::Main;XED::ILD"
      )
      ' >> $out/lib/cmake/xed/XEDConfig.cmake
    '';
  });

К уже существующему buildPhase добавляется скрипт, который создает файл XEDConfig.cmake в директории $out/lib/cmake/xed. Стоит заметить, что чтобы экранировать $, необходимо перед ним добавить '' (см. документацию).

Заморозка зависимостей

Рецепт выше является воспроизводимым, но не детерминированным. Версии пакетов и пакетного менеджера меняются и через некоторое время pkgs.fmt, pkgs.range-v3 и остальные зависимости будут иметь другие версии. Чтобы достичь детерминированности, необходимо заморозить версию пакетного менеджера. Для этого вместо:

{ pkgs ? import <nixpkgs> {} }:

Нужно написать:

{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/06278c77b5d162e62df170fec307e83f1812d94b.tar.gz") {} }:

Где 06278c77b5d162e62df170fec307e83f1812d94b.tar.gz - определенная версия Nixpkgs.

Заключение

nix-shell - это мощный инструмент, который значительно упрощает управление зависимостями и обеспечивает репродуцируемость среды разработки. В этой статье я рассказал что такое Nix, как с помощью nix-shell можно собрать воспроизводимую среду разработки и привел пример своего рецепта окружения. Кстати для локальной сборки этого блога я так же использую nix-shell:

with import <nixpkgs> {};
let
  env = pkgs.bundlerEnv rec {
    inherit ruby;
    name     = "jekyll-env";
    gemfile  = ./Gemfile;
    lockfile = ./Gemfile.lock;
    gemset   = ./gemset.nix;
  };
in 
  stdenv.mkDerivation rec {
    name = "blog";
    buildInputs = [ 
      bundler 
      ruby
      env 
    ];

    shellHook = ''
      exec ${env}/bin/jekyll serve --host 0.0.0.0 --watch
    '';
  }

Литература

  • https://nixos.org/guides/ad-hoc-developer-environments.html
  • https://devenv.sh/
  • https://zero-to-nix.com/
  • https://nix.dev/
  • https://medium.com/att-israel/how-nix-shell-saved-our-teams-sanity-a22fe6668d0e

Приложения

Полный рецепт окружения:

{ pkgs ? import <nixpkgs> {} }:
let
  stdenv = pkgs.llvmPackages_15.stdenv;

  z3 = stdenv.mkDerivation rec {
    version = "4.12.1";
    name = "z3-${version}";

    src = pkgs.fetchurl {
      url = "https://github.com/Z3Prover/z3/archive/refs/tags/z3-4.12.1.tar.gz";
      sha256 = "sha256-o3Nfq/AOE0GtzHA5SZPAX9PirhZ6Ppu0YEXjMITrZKM=";
    };

    nativeBuildInputs = [
      pkgs.cmake
    ];

    buildInputs = [
      pkgs.python39
    ];

    postPatch = "substituteInPlace z3.pc.cmake.in --replace '=\$\{exec_prefix\}/' '' --replace '=\$\{prefix\}/' ''";
  };

  triton = stdenv.mkDerivation rec {
    version = "dev-1.0";
    name = "triton-${version}";

    src = pkgs.fetchFromGitHub {
      owner = "JonathanSalwan";
      repo = "Triton";
      rev = "c344d78281ed9267d83820e06efe89baa27e12b2";
      sha256 = "sha256-NUkWUXsCIhX8el2By3zVVLZcmU09p4Vn4TcsbdEjIfU=";
    };

    cmakeFlags = [
      "-DBOOST_INTERFACE=OFF"
      "-DBUILD_EXAMPLES=OFF"
      "-DENABLE_TEST=OFF"
      "-DPYTHON_BINDINGS=OFF"
    ];

    nativeBuildInputs = [
      pkgs.cmake
    ];

    buildInputs = [
      pkgs.capstone
      z3
    ];
  };

  xed = pkgs.xed.overrideAttrs (finalAttrs: previousAttrs: {
    buildPhase = previousAttrs.buildPhase + ''
      mkdir -p $out/lib/cmake/xed/

      echo '
      set(XED_LIBRARY_DIR "''${CMAKE_CURRENT_LIST_DIR}/../../../lib")
      set(XED_LIBRARIES "''${XED_LIBRARY_DIR}/libxed''${CMAKE_STATIC_LIBRARY_SUFFIX}" "''${XED_LIBRARY_DIR}/libxed-ild''${CMAKE_STATIC_LIBRARY_SUFFIX}")
      set(XED_INCLUDE_DIRS "''${CMAKE_CURRENT_LIST_DIR}/../../../include")

      add_library(XED::Main STATIC IMPORTED)
      set_target_properties(XED::Main PROPERTIES
          IMPORTED_LOCATION "''${XED_LIBRARY_DIR}/libxed''${CMAKE_STATIC_LIBRARY_SUFFIX}"
      )

      add_library(XED::ILD STATIC IMPORTED)
      set_target_properties(XED::ILD PROPERTIES
          IMPORTED_LOCATION "''${XED_LIBRARY_DIR}/libxed-ild''${CMAKE_STATIC_LIBRARY_SUFFIX}"
      )

      add_library(XED::XED INTERFACE IMPORTED)
      set_target_properties(XED::XED PROPERTIES
          INTERFACE_INCLUDE_DIRECTORIES "''${XED_INCLUDE_DIRS}"
          INTERFACE_LINK_LIBRARIES "XED::Main;XED::ILD"
      )
      ' >> $out/lib/cmake/xed/XEDConfig.cmake
    '';
  });

in rec {
  project = stdenv.mkDerivation {
    name = "my-project";

    nativeBuildInputs = [
      pkgs.cmake
      pkgs.ninja
    ];

    buildInputs = [
      pkgs.range-v3
      pkgs.fmt
      pkgs.llvm_15
      z3
      xed
      triton
    ];
  };
}

CmakeLists.txt проекта:

cmake_minimum_required(VERSION 3.15)

set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

project(my_project)

# Packages
find_package(Z3 CONFIG REQUIRED)
find_package(fmt CONFIG REQUIRED)
find_package(LLVM 15.0 CONFIG REQUIRED)
find_package(triton CONFIG REQUIRED)
find_package(range-v3 CONFIG REQUIRED)
find_package(XED CONFIG REQUIRED)

llvm_map_components_to_libnames(llvm_libs
  support core irreader
  bitreader bitwriter
  passes asmprinter)

file(GLOB_RECURSE PROJECT_SOURCES  CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp")
file(GLOB_RECURSE PROJECT_INCLUDES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/*.hpp")
source_group(TREE ${PROJECT_SOURCE_DIR} FILES ${PROJECT_SOURCES} ${PROJECT_INCLUDES})

add_executable(${PROJECT_NAME} ${PROJECT_SOURCES} ${PROJECT_INCLUDES})

target_include_directories(${PROJECT_NAME} PRIVATE 
  "src/"
)

target_compile_features(${PROJECT_NAME} PRIVATE 
  cxx_std_20
)

target_link_libraries(${PROJECT_NAME} PRIVATE 
  fmt::fmt
  range-v3 
  ${llvm_libs}
  z3::libz3 
  triton::triton
  XED::XED
)