NixOS 系列文章目录:
NixOS 系列(一):我为什么心动了 NixOS 系列(二):基础配置,Nix Flake,和批量部署 推荐阅读: NixOS 与 Nix Flakes 新手入门 ,作者 Ryan Yin NixOS 系列(三):软件打包,从入门到放弃 NixOS 系列(四):「无状态」操作系统 NixOS 系列(五):制作小内存 VPS 的 DD 磁盘镜像 NixOS 的一大特点是,系统所有的二进制程序和库文件都在
/nix/store
目录中,由 Nix 包管理器管理。这也意味着,NixOS 不符合 Linux 的 FHS 标准 ,它的/lib
或/lib64
目录下不存在类似ld-linux-x86-64.so.2
之类的库文件动态加载器,更不存在libc.so
之类的库文件。因此,除非静态链接,否则为其它 Linux 下编译的二进制文件将完全无法在 NixOS 下运行。所以,要在 NixOS 上使用尚不存在于 Nixpkgs 仓库中的软件,最佳方案是自己用 Nix 语言写一份打包脚本,给这个软件打一个包,然后把打包定义加入
configuration.nix
中,从而安装到系统上。关于 NixOS 的软件打包,有三个好消息和两个坏消息。好消息是:
Nixpkgs,也就是 NixOS 的软件仓库,提供了大量的打包自动化函数,对于很多使用常见编程语言的开源软件(包括 C/C++,Python,Go,Node.js,Rust 等,但不包括 Java),你只需要调用现成的函数,指定一下源码的下载方式,Nixpkgs 就能自动检测软件的打包系统,自动传入合适的参数并完成软件打包。 对于以二进制方式分发的软件(常见于闭源软件),Nixpkgs 也提供了现成的自动化解决方案: 一种是 Autopatchelf,自动修改二进制文件中的库文件路径,将其指向 /nix/store
中。另一种是 Bubblewrap,或者基于 Bubblewrap 的 steam-run
,模拟一个符合 FHS 标准的运行环境。顾名思义,steam-run
主要针对的是 Steam 游戏平台以及它上面的游戏,但它也可以用于其它闭源软件。Nix 包管理器会在一个隔离的环境中进行软件打包,你可以粗略地理解成一个断网,限制权限,只允许访问固定路径的 Docker 容器。在编译过程中,访问外部路径或者联网的尝试全部会失败,只能使用 Nix 编译脚本中事先指定的依赖。因此,打包出来的程序将完全不依赖其它文件。 坏消息是:
开发者不一定比打包者更懂 Linux。开发者可能会在代码和编译脚本里写死各种路径,做出各种只符合 FHS 标准的假设。此时就需要你手动写补丁,纠正这些路径,让程序可以在 NixOS 上正常编译运行。 一旦你遇到了不能使用现成函数的情况,包括下列情况,你就得做好「一杯茶,一包烟,一个 Bug 调一天」的准备: 开发者使用了一些奇怪的源码目录结构(例如 osdlyrics
),或者非标准的编译方式程序主动检测运行环境(例如 UOS 版微信客户端) 程序主动检测对它本身的修改(例如 SVP 视频补帧软件) 我在几个月前将日常使用的发行版从 Arch Linux 换成了 NixOS,在使用过程中打了很多 NixOS 软件包。本文将从简单的打包开始一步步推进,介绍 NixOS 打包的方法,遇到的常见问题以及应对策略。
首先,强烈建议你安装好 NixOS 操作系统,并在 NixOS 上进行打包。
虽然在非 NixOS 的操作系统上也可以用 Nix 包管理器打包软件,但打包出的软件在运行过程中可能还会残留有对 FHS 标准目录的依赖,从而导致它们无法正常在 NixOS 上使用。当然,如果你打包只是自用,只考虑自己的运行环境,那可以忽略这条。 此外,要在非 NixOS 的操作系统上安装 Nix 打包的软件,你需要使用 Home Manager ,一个通过 Nix 语言的配置文件来管理你的 Home 目录下的软件配置文件的工具。你需要自行研究,或者查阅其它人的相关文章。 使用 NUR 的打包模版
NUR 是 Nix 的由用户自行管理的软件仓库,类似于 Arch Linux 的 AUR。NUR 提供了一份现成的 Nix 仓库模版,你可以方便地统一添加、管理自己的软件包。
在 GitHub 上,访问 nur-packages-template ,点击「Use this template」用这个模版建立一个仓库。之后,你可以将所有软件包统一保存在你新建的仓库。
如果要将自己的软件包发布到 NUR,你需要向 NUR 的主仓库 发起 Pull Request,将你自己的仓库地址加进去。但即使你不发 Pull Request,也完全可以直接使用自己的仓库。
然后,把你的仓库 Clone 下来。
对于不使用 Nix Flake 的用户,运行以下命令可以对
example-package
这个模版自带的示例软件包进行打包:nix-build -A example-package
对于使用 Flake 的用户,运行以下命令:
nix flake update # 可选,将 flake.lock 中的 Nixpkgs 等仓库更新到最新版 nix build ".#example-package"
然后,在你的 NixOS 配置中添加自己的仓库。
对于不使用 Nix Flake 的用户,在
configuration.nix
中添加如下定义:nixpkgs.config.packageOverrides = pkgs: { myRepo = import (builtins.fetchTarball "https://github.com/nix-community/nur-packages-template/archive/master.tar.gz") { inherit pkgs;
将
https://github.com/nix-community/nur-packages-template
替换成你的仓库地址。这样操作后,你就能用类似于
pkgs.myRepo.example-package
的方式使用你打的包了。对于使用 Nix Flake 的用户,在
flake.nix
中的inputs
一节中添加如下定义:inputs = { # ... myRepo = { url = "github:nix-community/nur-packages-template"; inputs.nixpkgs.follows = "nixpkgs"; # ...
将
nix-community/nur-packages-template
替换成你的仓库地址。然后,在
flake.nix
中的output
一节,你的nixosConfigurations
定义中,为每个系统添加一个 module:outputs = { self, nixpkgs, ... }@inputs: { nixosConfigurations."nixos" = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ # 在 modules 的开头添加下面这几行 nixpkgs.overlays = [ (final: prev: { myRepo = inputs.myRepo.packages."${prev.system}"; # 在 modules 的开头添加上面这几行 ./configuration.nix
这样操作后,你就能用类似于
pkgs.myRepo.example-package
的方式使用你打的包了。直接在 NixOS 配置文件中添加软件包
当然,你也可以不使用 NUR 的模版,而是直接把打包定义和 NixOS 的配置文件放在一起。
假设你有这样一个打包定义,保存成
example-package.nix
:(来自 https://github.com/nix-community/nur-packages-template/blob/master/pkgs/example-package/default.nix){ stdenv }: stdenv.mkDerivation rec { name = "example-package-${version}"; version = "1.0"; src = ./.; buildPhase = "echo echo Hello World > example"; installPhase = "install -Dm755 example $out";
你可以在
configuration.nix
中使用pkgs.callPackage
函数来调用它:{ config, pkgs, ... }: # 直接使用这个包 environment.systemPackages = [ (pkgs.callPackage ./example-package.nix { }) # 或者将这个包先定义成一个常量 environment.systemPackages = let examplePackage = pkgs.callPackage ./example-package.nix { }; examplePackage
如果你要单独尝试构建这个软件包,你可以使用以下命令:
nix-build -E 'with import <nixpkgs> {}; callPackage ./example-package.nix {}'
虽然你可以直接调用 Nix 包管理器内置的
builtins.derivation
函数进行打包,但我们一般用更为方便的stdenv.mkDerivation
函数来生成一个 Nix 包管理器的打包定义。相比于builtins.derivation
,stdenv.mkDerivation
将打包过程分成了 7 个步骤(Phase):解压(Unpack phase)
在这一步中,
stdenv.mkDerivation
会自动解压src
参数指定的源码包。例如如果你的源码包是.tar.gz
格式的,就会自动调用tar xf
。但
stdenv.mkDerivation
不能识别所有压缩格式,例如.zip
就不行,需要手动指定解压命令:nativeBuildInputs = [ unzip ]; unpackPhase = '' unzip $src
stdenv.mkDerivation
要求源码包的顶层是一个文件夹,解压完成后会自动cd
配置(Configure phase)
这一步相当于运行 ./configure
或者cmake
。stdenv.mkDerivation
会自动检测打包方案并调用相应命令,或者当相应配置文件不存在时,自动跳过这一步。需要注意的是,要调用 cmake
,你需要额外加一行nativeBuildInputs = [ cmake ];
把 CMake 加入打包环境中。你可以用 configureFlags
或者cmakeFlags
添加配置参数,例如启用/禁用软件的功能。编译(Build phase)
这一步相当于运行 make
。你可以用makeFlags
添加传给make
的参数。测试(Check phase)
这一步会运行源码中自带的测试用例,以保证软件功能正确。 你可以用 doCheck = false;
禁用这一步。安装(Install phase)
这一步相当于运行 make install
,将编译结果复制到 Nix store 的相应文件夹中。整个构建过程是在临时文件夹中,而不是 Nix store 中进行的,因此需要这一步将文件复制过去。 当你手动指定安装命令时,目标路径存在变量 $out
中。$out
可以是存放有文件的文件夹,也可以直接是一个文件。额外修补(Fixup phase)
这一步会对 Nix store 中的结果做一些清理,例如去除调试符号等。 Autopatchelf Hook,一个自动替换闭源软件 .so
的路径的 Hook,就是在这一步运行的。你可以用 dontFixup = true;
禁用这一步。每一个步骤都可以手动指定对应的命令,或者在原有命令之前或之后额外增加命令。以安装这一步为例:
preInstall = '' echo 这里指定在安装步骤之前运行的命令 installPhase = '' # 运行 preInstall 的命令。默认的 installPhase 自带了下面这一行,但当你指定整个步骤的命令时,就需要自己加上,否则 preInstall 不会运行 runHook preInstall echo 这里指定安装步骤的所有命令 # 运行 postInstall 的命令,同理 runHook postInstall postInstall = '' echo 这里指定在安装步骤之后运行的命令
只看这些步骤的解释可能有些抽象,因此接下来我会给出一些实例,并给出详细解释。此外,我的实例中还会涉及 Nixpkgs 提供的对于几种常用编程语言的专用打包函数,例如 Python 的
buildPythonPackage
,Go 的buildGoModule
等等。这些实例都来自我的 NUR 软件源。实例:开源软件
开源软件的打包往往都比较容易,因为在打包过程中,Nix 包管理器会调整好环境变量,让编译器找到存放在 Nix store 中其它路径的库文件,所以生成的二进制文件都会链接到 Nix store 的库文件中,不依赖
/usr
等路径下的其它文件,可以直接在 NixOS 上使用,此外,即使开源软件中出现路径写死等情况,你在打包过程中也可以写一个补丁,把路径修改掉,从而让它能在 NixOS 下正常工作。简单:LibOQS(C++,CMake,自动化构建)
首先我们来看一个最简单的例子:LibOQS。LibOQS 提供了多种后量子加密算法的实现,可以用来给 OpenSSL 或 BoringSSL 提供后量子加密支持。
LibOQS 使用 CMake 构建,并且本身没有任何依赖,因此基本上所有工作都可以由
stdenv.mkDerivations
自动完成,我们只需要为 CMake 指定几个额外的参数:# 当你使用 pkgs.callPackage 函数时,这里的参数会用 Nixpkgs 的软件包和函数自动填充(如果有对应的话) { lib , stdenv , fetchFromGitHub , cmake , ... } @ args: stdenv.mkDerivation rec { # 指定包名和版本 pname = "liboqs"; version = "0.7.1"; # 从 GitHub 下载源代码 src = fetchFromGitHub ({ owner = "open-quantum-safe"; repo = "liboqs"; # 对应的 commit 或者 tag,注意 fetchFromGitHub 不能跟随 branch! rev = "0.7.1"; # 下载 git submodules,绝大部分软件包没有这个 fetchSubmodules = false; # 这里的 SHA256 校验码不会算怎么办?先注释掉,然后构建这个软件包,Nix 会报错,并提示你正确的校验码 sha256 = "sha256-m20M4+3zsH40hTpMJG9cyIjXp0xcCUBS+cCiRVLXFqM="; # 并行编译,大幅加快打包速度,默认是启用的。对于极少数并行编译会失败的软件包,才需要禁用。 enableParallelBuilding = true; # 如果基于 CMake 的软件包在打包时出现了奇怪的错误,可以尝试启用此选项 # 此选项禁用了对 CMake 软件包的一些自动修正 dontFixCmake = true; # 将 CMake 加入编译环境,用来生成 Makefile nativeBuildInputs = [ cmake ]; # 传给 CMake 的配置参数,控制 liboqs 的功能 cmakeFlags = [ "-DBUILD_SHARED_LIBS=ON" "-DOQS_BUILD_ONLY_LIB=1" "-DOQS_USE_OPENSSL=OFF" "-DOQS_DIST_BUILD=ON" # stdenv.mkDerivation 自动帮你完成其余的步骤
然后运行下面这行命令,Nix 包管理器就会自动构建这个软件包,并把输出链接到当前目录的
results
。nix-build -E 'with import <nixpkgs> {}; callPackage ./liboqs.nix {}'
中等:openssl-oqs-provider(C,增加依赖)
有了 LibOQS,我们可以再打包一个 OpenSSL OQS Provider,一个 OpenSSL 3.0 的加解密引擎,可以把后量子加密算法加入 OpenSSL 3.0 中。
{ lib , stdenv , fetchFromGitHub , cmake , liboqs , openssl_3_0 , python3 , ... } @ args: stdenv.mkDerivation rec { pname = "openssl-oqs-provider"; version = "ec60cde5cc894814016f821a1162fe1a4b888a75"; src = fetchFromGitHub ({ owner = "open-quantum-safe"; repo = "oqs-provider"; rev = "ec60cde5cc894814016f821a1162fe1a4b888a75"; fetchSubmodules = false; sha256 = "sha256-NyT5CpQeclSJ0b4Qr4McAJXwKgy6SWiUijkAgu6TTNM="; enableParallelBuilding = true; dontFixCmake = true; # nativeBuildInputs 指定的是只有在构建时用到,运行时不会用到的软件包 # 例如这里的用来生成 Makefile 的 CMake,和用来生成配置文件的 Python nativeBuildInputs = [ cmake # 向打包环境加入 Python 和这几个包,preConfigure 中的命令需要用到 (python3.withPackages (p: with p; [ jinja2 pyyaml tabulate ])) # buildInputs 指定的是运行时也会用到的软件包 buildInputs = [ liboqs openssl_3_0 # 在配置步骤(Configure phase)之前运行的命令,用来启用所有的后量子加密算法 preConfigure = '' cp ${sources.openssl-oqs.src}/oqs-template/generate.yml oqs-template/generate.yml sed -i "s/enable: false/enable: true/g" oqs-template/generate.yml LIBOQS_SRC_DIR=${sources.liboqs.src} python oqs-template/generate.py cmakeFlags = [ "-DCMAKE_BUILD_TYPE=Release" ]; # 手动指定安装命令,把 oqsprovider.so 复制到 $out/lib 文件夹下 # 一般来说可执行文件放在 $out/bin,库文件放在 $out/lib,菜单图标等放在 $out/share # 但并非强制,你在 $out 下随便放都可以,只不过在其它地方调用会麻烦一些 installPhase = '' mkdir -p $out/lib install -m755 oqsprov/oqsprovider.so "$out/lib"
这个包主要用来展示
nativeBuildInputs
和buildInputs
的区别:nativeBuildInputs
只有在构建时用到,一般用来生成一些配置文件或者编译脚本。在交叉编译(给其它架构的设备编译软件)时,nativeBuildInputs
的架构会和运行编译的设备相同,而不是和目标设备相同。例如用 x86 电脑给 ARM 树莓派编译时,nativeBuildInputs
的架构会是 x86。buildInputs
在构建和最终运行软件时都会用到。所有的依赖库都会放到这里。这些依赖的架构和目标设备相同,例如openssl-oqs-provider
依赖的liboqs
必然和它是同一架构的(都是 x86 或者都是 ARM)。困难:OSDLyrics(Python 和 C++,两轮构建)
接下来我们来看 OSDLyrics,一个桌面歌词软件。这个包表面上看起来很好打,官方给出的编译命令就是下面几行:
./autogen.sh ./configure --prefix=/usr PYTHON=/usr/bin/python3 sudo make install
但是编译命令里出现了 Python,这就比较麻烦了。OSDLyrics 由 Python 和 C++ 两部分组成,其中 C++ 部分会调用 Python 的库。因此,官方的编译脚本会把 OSDLyrics 的 Python 模块安装到 Python 的
site-packages
文件夹中。但是在 Nix 中,对于 OSDLyrics 这个软件包来说,Python 的安装目录是只读的,自然无法安装这个模块。因此我们需要先给 Python 模块部分单独打个包:
{ python3Packages , fetchFromGitHub , writeText , ... python3Packages.buildPythonPackage rec { pname = "osdlyrics"; version = "0.5.10"; src = fetchFromGitHub ({ owner = "osdlyrics"; repo = "osdlyrics"; rev = "0.5.10"; fetchSubmodules = false; sha256 = "sha256-x9gIT1JkfPIc4RmmQJLv9rOG2WqAftoTK5uiRlS65zU="; configurePhase = # 原软件包的 Python 模块部分不符合 PIP 的打包格式,需要手动加入这两个配置文件 setupPy = writeText "setup.py" '' from setuptools import setup, find_packages setup( name='${pname}', version='${version}', packages=['osdlyrics', 'osdlyrics/dbusext'], initPy = writeText "__init__.py" '' PROGRAM_NAME = 'OSD Lyrics' PACKAGE_NAME = '${pname}' PACKAGE_VERSION = '${version}' # 把 Python 模块的文件夹改名并加入配置文件,以符合 PIP 规范 ln -s ${setupPy} setup.py mv python osdlyrics ln -s ${initPy} osdlyrics/__init__.py # 禁用测试,原软件包中没有单元测试 doCheck = false;
然后把这个模块加入 OSDLyrics 最终使用的 Python 环境:
{ python3Packages , fetchFromGitHub , writeText , python3 , ... osdlyricsPython = python3Packages.buildPythonPackage rec { # ... # 下面列出的包都是 OSDLyrics 要用到的 python = python3.withPackages (p: with p; [ chardet dbus-python future osdlyricsPython pycurl pygobject3 # ...
最终才能打包它的 C++ 部分:
{ ... }: # ... stdenv.mkDerivation rec { pname = "osdlyrics"; version = "0.5.10"; src = fetchFromGitHub ({ owner = "osdlyrics"; repo = "osdlyrics"; rev = "0.5.10"; fetchSubmodules = false; sha256 = "sha256-x9gIT1JkfPIc4RmmQJLv9rOG2WqAftoTK5uiRlS65zU="; nativeBuildInputs = [ # 自动运行 autoconf,也就是 autogen.sh 做的事 autoreconfHook # 生成语言文件的工具 intltool # pkgconfig 被 autoconf 系列配置脚本用来查找依赖 pkg-config # C++ 部分用到的依赖 buildInputs = [ dbus-glib libnotify # 注意这个 Python 是我们上面定义的,加了几个模块的版本 python # 解决一些编译错误 postPatch = '' sed -i 's/-Werror//g' configure.ac # autoreconfHook 会在构建步骤中加入一个 autoreconf phase,也有对应的前置/后置命令 Hook preAutoreconf = '' export AUTOPOINT=intltoolize # 指定用我们的加了模块的 Python makeFlags = [ "PYTHON=${python}/bin/python" ]; # 删除结果中的 Python 模块部分(因为已经打包过了) postInstall = '' rm -rf $out/lib/python*
最终完整的定义如下:
{ stdenv , lib , fetchFromGitHub , writeText , python3Packages # nativeBuildInputs , autoreconfHook , intltool , pkg-config # buildInputs , dbus-glib , gtk2 , libnotify , python3 , ... } @ args: pname = "osdlyrics"; version = "0.5.10"; src = fetchFromGitHub ({ owner = "osdlyrics"; repo = "osdlyrics"; rev = "0.5.10"; fetchSubmodules = false; sha256 = "sha256-x9gIT1JkfPIc4RmmQJLv9rOG2WqAftoTK5uiRlS65zU="; osdlyricsPython = python3Packages.buildPythonPackage rec { inherit pname version src; configurePhase = setupPy = writeText "setup.py" '' from setuptools import setup, find_packages setup( name='${pname}', version='${version}', packages=['osdlyrics', 'osdlyrics/dbusext'], initPy = writeText "__init__.py" '' PROGRAM_NAME = 'OSD Lyrics' PACKAGE_NAME = '${pname}' PACKAGE_VERSION = '${version}' ln -s ${setupPy} setup.py mv python osdlyrics ln -s ${initPy} osdlyrics/__init__.py doCheck = false; python = python3.withPackages (p: with p; [ chardet dbus-python future osdlyricsPython pycurl pygobject3 stdenv.mkDerivation rec { inherit pname version src; nativeBuildInputs = [ autoreconfHook intltool pkg-config buildInputs = [ dbus-glib libnotify python postPatch = '' sed -i 's/-Werror//g' configure.ac preAutoreconf = '' export AUTOPOINT=intltoolize makeFlags = [ "PYTHON=${python}/bin/python" ]; postInstall = '' rm -rf $out/lib/python*
实例:闭源软件(以及以二进制形式分发的软件)
比起开源软件,给闭源软件打包就比较困难了。这些闭源软件往往只提供二进制文件,而这些二进制文件往往是提供给传统的、使用 FHS 标准目录结构的 Linux 发行版的,例如 CentOS、Debian、Ubuntu 等。由于我们没有源代码,我们只能想办法在二进制文件上动手术,在二进制文件中查找 FHS 标准路径,并把它们全部替换成 Nix store 的路径。
幸运的是,针对不同的情况,Nixpkgs 提供了好几种方案,让多数的闭源软件都能打包成功。
简单:Bilibili-linux(解压 DEB 包,Electron)
首先我们看一个简单的情况:基于 Electron 的软件。这里以 Bilibili-linux 为例,它是基于哔哩哔哩官方的桌面客户端移植到 Linux 系统的版本。
虽然 Electron 软件相比传统的基于 GTK 或 Qt 的桌面软件耗电大,占用空间多,而且会让每台电脑中都装上十来个 Chromium,让它的市场占有率飙升到 1000% 以上,但它的移植便捷性不容忽视。Bilibili-linux 这个客户端是使用纯 Javascript 实现的,软件包里除了 Electron 之外,没有任何其它的二进制文件。因此我们可以取出它的 Javascript 代码,然后直接用系统的 Electron 运行。
{ stdenv , fetchurl , electron , lib , makeWrapper , ... } @ args: ################################################################################ # Mostly based on bilibili-bin package from AUR: # https://aur.archlinux.org/packages/bilibili-bin ################################################################################ stdenv.mkDerivation rec { pname = "bilibili"; version = "1.2.1-1"; src = fetchurl { url = "https://github.com/msojocs/bilibili-linux/releases/download/v1.2.1-1/io.github.msojocs.bilibili_1.2.1-1_amd64.deb"; sha256 = "sha256-t/igezm0ipkOkKION8qTYGK9f6qI3c4iPuS/wWrMywQ="; # 解压 DEB 包 unpackPhase = '' ar x ${src} tar xf data.tar.xz # makeWrapper 可以自动生成一个调用其它命令的命令(也就是 wrapper),并且可以在原命令上修改参数、环境变量等 buildInputs = [ makeWrapper ]; installPhase = '' mkdir -p $out/bin # 替换菜单项目(desktop 文件)中的路径 cp -r usr/share $out/share sed -i "s|Exec=.*|Exec=$out/bin/bilibili|" $out/share/applications/*.desktop # 复制出客户端的 Javascript 部分,其它的不要了 cp -r opt/apps/io.github.msojocs.bilibili/files/bin/app $out/opt # 生成 bilibili 命令,运行这个命令时会调用 electron 加载客户端的 Javascript 包($out/opt/app.asar) makeWrapper ${electron}/bin/electron $out/bin/bilibili \ --argv0 "bilibili" \ --add-flags "$out/opt/app.asar"
中等:DingTalk(自动 Patch 二进制,查找依赖)
当然,不是所有闭源软件都用的是 Electron 方案。对于有二进制文件的闭源软件,我们就需要在二进制文件上动刀了,把它的依赖库文件全部改成 Nix store 里的库。Nixpkgs 提供了一个方便的工具
autoPatchelfHook
,它会搜索软件包里的所有二进制,并修改所有的依赖路径,当有依赖路径没被满足时会自动报错,方便调试。我们这次用的例子是 DingTalk,钉钉的 Linux 客户端,它使用 GTK 作为界面框架。由于我们一开始不知道钉钉有什么依赖,我们先编写一个大致的打包模版:
{ stdenv , fetchurl , autoPatchelfHook , makeWrapper , lib , callPackage , ... } @ args: ################################################################################ # Mostly based on dingtalk-bin package from AUR: # https://aur.archlinux.org/packages/dingtalk-bin ################################################################################ stdenv.mkDerivation rec { pname = "dingtalk"; version = "1.4.0.20425"; src = fetchurl { url = "https://dtapp-pub.dingtalk.com/dingtalk-desktop/xc_dingtalk_update/linux_deb/Release/com.alibabainc.dingtalk_${version}_amd64.deb"; sha256 = "sha256-UKkFuuFK/Ae+XIWbPYYsqwS/FOJfOqm9e1i18JB8UfA="; # autoPatchelfHook 可以自动修改二进制文件 nativeBuildInputs = [ autoPatchelfHook makeWrapper ]; unpackPhase = '' ar x ${src} tar xf data.tar.xz mv opt/apps/com.alibabainc.dingtalk/files/version version mv opt/apps/com.alibabainc.dingtalk/files/*-Release.* release # 删除一些可以用系统库替代的库文件,和没用的 exe 等文件 rm -rf release/Resources/{i18n/tool/*.exe,qss/mac} rm -f release/{*.a,*.la,*.prl} rm -f release/dingtalk_updater rm -f release/libgtk-x11-2.0.so.* rm -f release/libm.so.* installPhase = '' mkdir -p $out mv version $out/ # 有些库文件必须使用钉钉自带的版本 mv release $out/lib # 这里的 desktop 文件和图标是从 AUR 拿的 mkdir -p $out/share/applications $out/share/pixmaps ln -s ${./dingtalk.desktop} $out/share/applications/dingtalk.desktop ln -s ${./dingtalk.png} $out/share/pixmaps/dingtalk.png
然后尝试打包。不出所料,报错了:
# ... > auto-patchelf failed to find all the required dependencies. > Add the missing dependencies to --libs or use `--ignore-missing="foo.so.1 bar.so etc.so"`. For full logs, run 'nix log /nix/store/gm3d0jm6l19ypcz6vfmv5hmx8d9iygr1-dingtalk-1.4.0.20425.drv'.
我们运行上面这行命令查看完整的日志:
# ... error: auto-patchelf could not satisfy dependency libX11.so.6 wanted by /nix/store/w179pb9w545rwnhvv0kkcjvra0gv82sp-dingtalk-1.4.0.20425/lib/cefclient error: auto-patchelf could not satisfy dependency libgtk-x11-2.0.so.0 wanted by /nix/store/w179pb9w545rwnhvv0kkcjvra0gv82sp-dingtalk-1.4.0.20425/lib/cefclie error: auto-patchelf could not satisfy dependency libgdk_pixbuf-2.0.so.0 wanted by /nix/store/w179pb9w545rwnhvv0kkcjvra0gv82sp-dingtalk-1.4.0.20425/lib/cefc lient error: auto-patchelf could not satisfy dependency libgobject-2.0.so.0 wanted by /nix/store/w179pb9w545rwnhvv0kkcjvra0gv82sp-dingtalk-1.4.0.20425/lib/cefclie error: auto-patchelf could not satisfy dependency libglib-2.0.so.0 wanted by /nix/store/w179pb9w545rwnhvv0kkcjvra0gv82sp-dingtalk-1.4.0.20425/lib/cefclient # ...
autoPatchelfHook
已经列出了所有缺失的库文件,接下来,我们需要一个一个查找这些库文件对应的软件包,并把它们加入软件包的依赖buildInputs
中。你可以根据自己的经验在 NixOS Search 上查找软件包,也可以使用 nix-index,一个根据文件名搜包的工具,来加快查找速度。最后添加完后,DingTalk 包的定义是这样的:
{ stdenv , fetchurl , autoPatchelfHook , makeWrapper , lib , callPackage # DingTalk dependencies , alsa-lib , at-spi2-atk , at-spi2-core , cairo , cups , dbus , e2fsprogs , gdk-pixbuf , glib , gnutls , graphite2 , gtk2 , krb5 , libdrm , libgcrypt , libGLU , libpulseaudio , libthai , libxkbcommon , mesa_drivers , nspr , nss , rtmpdump , udev , util-linux , xorg , ... } @ args: ################################################################################ # Mostly based on dingtalk-bin package from AUR: # https://aur.archlinux.org/packages/dingtalk-bin ################################################################################ version = "1.4.0.20425"; # 钉钉依赖旧版本的 OpenLDAP,openldap-2_4.nix 这个定义可以在我的 NUR 找到 openldap = callPackage ./openldap-2_4.nix { }; libraries = [ alsa-lib at-spi2-atk at-spi2-core cairo e2fsprogs gdk-pixbuf gnutls graphite2 libdrm libgcrypt libGLU libpulseaudio libthai libxkbcommon mesa_drivers openldap rtmpdump util-linux xorg.libICE xorg.libSM xorg.libX11 xorg.libxcb xorg.libXcomposite xorg.libXcursor xorg.libXdamage xorg.libXext xorg.libXfixes xorg.libXi xorg.libXinerama xorg.libXmu xorg.libXrandr xorg.libXrender xorg.libXScrnSaver xorg.libXt xorg.libXtst stdenv.mkDerivation rec { pname = "dingtalk"; inherit version; src = fetchurl { url = "https://dtapp-pub.dingtalk.com/dingtalk-desktop/xc_dingtalk_update/linux_deb/Release/com.alibabainc.dingtalk_${version}_amd64.deb"; sha256 = "sha256-UKkFuuFK/Ae+XIWbPYYsqwS/FOJfOqm9e1i18JB8UfA="; nativeBuildInputs = [ autoPatchelfHook makeWrapper ]; buildInputs = libraries; unpackPhase = '' ar x ${src} tar xf data.tar.xz mv opt/apps/com.alibabainc.dingtalk/files/version version mv opt/apps/com.alibabainc.dingtalk/files/*-Release.* release # Cleanup rm -rf release/Resources/{i18n/tool/*.exe,qss/mac} rm -f release/{*.a,*.la,*.prl} rm -f release/dingtalk_updater rm -f release/libgtk-x11-2.0.so.* rm -f release/libm.so.* installPhase = '' mkdir -p $out mv version $out/ # Move libraries # DingTalk relies on (some of) the exact libraries it ships with mv release $out/lib # Entrypoint mkdir -p $out/bin # 因为钉钉客户端会在运行过程中动态加载库文件,所以要把所有的依赖项加入 LD_LIBRARY_PATH,让钉钉客户端能找到 makeWrapper $out/lib/com.alibabainc.dingtalk $out/bin/dingtalk \ --argv0 "com.alibabainc.dingtalk" \ --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath libraries}" # App Menu mkdir -p $out/share/applications $out/share/pixmaps ln -s ${./dingtalk.desktop} $out/share/applications/dingtalk.desktop ln -s ${./dingtalk.png} $out/share/pixmaps/dingtalk.png
困难:SVP(程序检测自身完整性,Bubblewrap)
以钉钉客户端为例的闭源软件虽然打包麻烦,需要手动查找所有的依赖库,反复测试,但至少软件本身不会给你下绊子。有些闭源软件为了防止破解,会检测自身的完整性,只要自己的二进制文件被修改就拒绝启动,例如 SVP 视频补帧软件。
对于这些软件,
autoPatchelfHook
自然用不了了。因此我们只能换成另一种办法:生成一个符合 FHS 标准的虚拟环境,把所有的库文件放在虚拟环境中对应的路径,然后在虚拟环境中启动软件。最常用的创建虚拟环境的软件是 Bubblewrap,它原本的用途是把软件放在沙盒中,阻止它读取敏感数据,但这个沙盒正好也可以是我们要用的虚拟环境。我们直接来看 SVP 的打包定义:
{ stdenv , bubblewrap , fetchurl # SVP 的所有依赖 , ffmpeg , glibc , gnome , lib , libmediainfo , libsForQt5 , libusb1 , lsof , makeWrapper , mpv-unwrapped # NVIDIA 驱动,SVP 需要调用其中的一个库来支持 N 卡光流加速 # 在 N 卡系统上需要用户手动 override 成自己的驱动版本 # 在非 N 卡系统上可以设置成 null , nvidia_x11 ? null , ocl-icd , p7zip , patchelf , vapoursynth , wrapMpv , writeShellScript , writeText , xdg-utils , xorg , ... ################################################################################ # Based on svp package from AUR: # https://aur.archlinux.org/packages/svp ################################################################################ # 打包一个加了 N 卡光流库,并开启 Vapoursynth 视频处理引擎的 MPV mpvForSVP = wrapMpv (mpv-unwrapped.override { vapoursynthSupport = true; extraMakeWrapperArgs = lib.optionals (nvidia_x11 != null) [ "--prefix" "LD_LIBRARY_PATH" "${lib.makeLibraryPath [ nvidia_x11 ]}" # SVP 主程序的依赖 libPath = lib.makeLibraryPath [ libsForQt5.qtbase libsForQt5.qtdeclarative libsForQt5.qtscript libsForQt5.qtsvg libmediainfo libusb1 xorg.libX11 stdenv.cc.cc.lib ocl-icd vapoursynth # SVP 查找二进制程序的路径(即 PATH 环境变量) execPath = lib.makeBinPath [ ffmpeg.bin gnome.zenity xdg-utils svp-dist = stdenv.mkDerivation rec { pname = "svp-dist"; version = "4.5.210"; src = fetchurl { url = "https://www.svp-team.com/files/svp4-linux.${version}-1.tar.bz2"; sha256 = "10q8r401wg81vanwxd7v07qrh3w70gdhgv5vmvymai0flndm63cl"; nativeBuildInputs = [ p7zip patchelf ]; # 禁用修补步骤(Fixup phase),它会修改 SVP 二进制文件,导致完整性校验报错 dontFixup = true; # 解压、安装步骤来自 AUR:https://aur.archlinux.org/packages/svp-bin unpackPhase = '' tar xf ${src} buildPhase = '' mkdir installer LANG=C grep --only-matching --byte-offset --binary --text $'7z\xBC\xAF\x27\x1C' "svp4-linux-64.run" | cut -f1 -d: | while read ofs; do dd if="svp4-linux-64.run" bs=1M iflag=skip_bytes status=none skip=$ofs of="installer/bin-$ofs.7z"; done installPhase = '' mkdir -p $out/opt for f in "installer/"*.7z; do 7z -bd -bb0 -y x -o"$out/opt/" "$f" || true for SIZE in 32 48 64 128; do mkdir -p "$out/share/icons/hicolor/''${SIZE}x''${SIZE}/apps" mv "$out/opt/svp-manager4-''${SIZE}.png" "$out/share/icons/hicolor/''${SIZE}x''${SIZE}/apps/svp-manager4.png" rm -f $out/opt/{add,remove}-menuitem.sh # 创建一个使用 Bubblewrap 的启动脚本 startScript = writeShellScript "SVPManager" '' # 除了这些路径以外,其它的根目录下的路径都映射进虚拟环境 # 这里的有些路径不是完全不映射,而是在下面有更细粒度的映射配置 blacklist=(/nix /dev /usr /lib /lib64 /proc) declare -a auto_mounts # loop through all directories in the root for dir in /*; do # if it is a directory and it is not in the blacklist if [[ -d "$dir" ]] && [[ ! "''${blacklist[@]}" =~ "$dir" ]]; then # add it to the mount list auto_mounts+=(--bind "$dir" "$dir") # Bubblewrap 启动脚本 cmd=( ${bubblewrap}/bin/bwrap # /dev 需要特殊的映射方式 --dev-bind /dev /dev # 在虚拟环境中也切换到当前文件夹 --chdir "$(pwd)" # Bubblewrap 退出时杀掉虚拟环境里的所有进程 --die-with-parent # /nix 目录只读 --ro-bind /nix /nix # /proc 需要特殊的映射方式 --proc /proc # 把 Glibc 放到 /lib 和 /lib64,让 SVP 加载 --bind ${glibc}/lib /lib --bind ${glibc}/lib /lib64 # 一些 SVP 需要用到的命令,SVP 固定去 /usr/bin 查找这些命令 --bind /usr/bin/env /usr/bin/env --bind ${ffmpeg.bin}/bin/ffmpeg /usr/bin/ffmpeg --bind ${lsof}/bin/lsof /usr/bin/lsof # 配置环境变量,包括查找命令和库的路径 --setenv PATH "${execPath}:''${PATH}" --setenv LD_LIBRARY_PATH "${libPath}:''${LD_LIBRARY_PATH}" # 把 SVP 专用的 MPV 播放器映射过来 --symlink ${mpvForSVP}/bin/mpv /usr/bin/mpv # 映射其它根目录下的路径 "''${auto_mounts[@]}" # 虚拟环境启动后运行 SVP 主程序 ${svp-dist}/opt/SVPManager "$@" exec "''${cmd[@]}" # SVP 菜单项 desktopFile = writeText "svp-manager4.desktop" '' [Desktop Entry] Version=1.0 Encoding=UTF-8 Name=SVP 4 Linux GenericName=Real time frame interpolation Type=Application Categories=Multimedia;AudioVideo;Player;Video; MimeType=video/x-msvideo;video/x-matroska;video/webm;video/mpeg;video/mp4; Terminal=false StartupNotify=true Exec=${startScript} %f Icon=svp-manager4.png # 创建一个简单的包,只包含启动脚本和菜单项 stdenv.mkDerivation { pname = "svp"; inherit (svp-dist) version; phases = [ "installPhase" ]; installPhase = '' mkdir -p $out/bin $out/share/applications ln -s ${startScript} $out/bin/SVPManager ln -s ${desktopFile} $out/share/applications/svp-manager4.desktop ln -s ${svp-dist}/share/icons $out/share/icons
困难:WeChat-UOS(程序检测运行环境,Steam-run)
另一个会检测运行环境的是 UOS 版微信客户端。虽然它本身是一个 Electron 应用,打包应该很简单,但是它自带了一个库文件,包含有检测 UOS 系统授权文件的逻辑,检测失败就拒绝你登录。因此,我们依然需要构造一个虚拟环境,把 UOS 的授权文件放到对应的位置,才能正常使用微信。
这里展示 Nixpkgs 中的一个便捷打包工具:
steam-run
。steam-run
本身就是调用的 Bubblewrap,但是顾名思义,steam-run
原本是用来运行 Steam 客户端和 Steam 上的游戏的,因此它的默认环境包含了大量常用的库文件,很多闭源软件都能用它跑起来。{ stdenv , fetchurl , writeShellScript , electron , steam , lib , scrot , ... } @ args: ################################################################################ # Mostly based on wechat-uos package from AUR: # https://aur.archlinux.org/packages/wechat-uos ################################################################################ version = "2.1.4"; # UOS 授权文件,从 AUR 下载:https://aur.archlinux.org/packages/wechat-uos license = stdenv.mkDerivation rec { pname = "wechat-uos-license"; version = "0.0.1"; src = ./license.tar.gz; installPhase = '' mkdir -p $out cp -r etc var $out/ # 微信软件包,和 B 站客户端一样只保留 Javascript 部分和几个需要的库 resource = stdenv.mkDerivation rec { pname = "wechat-uos-resource"; inherit version; src = fetchurl { url = "https://home-store-packages.uniontech.com/appstore/pool/appstore/c/com.tencent.weixin/com.tencent.weixin_${version}_amd64.deb"; sha256 = "sha256-V74m+dFK9/f0QoHfvIjk7hyIil6FpV9HGkPqwJLvQhM="; unpackPhase = '' ar x ${src} installPhase = '' mkdir -p $out tar xf data.tar.xz -C $out mv $out/usr/* $out/ mv $out/opt/apps/com.tencent.weixin/files/weixin/resources/app $out/lib/wechat-uos chmod 0644 $out/lib/license/libuosdevicea.so rm -rf $out/opt $out/usr # use system scrot pushd $out/lib/wechat-uos/packages/main/dist/ sed -i 's|__dirname,"bin","scrot"|"${scrot}/bin/"|g' index.js # 生成一个 Steam-run 虚拟环境,这里包含了 UOS 授权文件和微信软件包 steam-run = (steam.override { extraPkgs = p: [ license resource ]; runtimeOnly = true; }).run; # 微信启动脚本 startScript = writeShellScript "wechat-uos" '' # 目前版本的微信在 NixOS 上无法显示托盘图标,如果关掉窗口有可能就再也找不到了 # 因此如果微信运行着,就直接把它杀掉,这样就可以重新启动微信了 wechat_pid=`pidof wechat-uos` if test $wechat_pid; then kill -9 $wechat_pid # 用 Steam-run 在虚拟环境中启动微信 ${steam-run}/bin/steam-run \ ${electron}/bin/electron \ ${resource}/lib/wechat-uos # 创建一个简单的包,只包含启动脚本和菜单项 stdenv.mkDerivation { pname = "wechat-uos"; inherit version; phases = [ "installPhase" ]; installPhase = '' mkdir -p $out/bin $out/share/applications ln -s ${startScript} $out/bin/wechat-uos ln -s ${./wechat-uos.desktop} $out/share/applications/wechat-uos.desktop ln -s ${resource}/share/icons $out/share/icons
steam-run
虽然好用,但因为它为了支持大量的 Steam 游戏默认引入了大量的库文件,如果只为了运行一些简单的程序,不免有些大材小用。因此我建议,对于简单的软件尽量用 Bubblewrap 手动打包,对于复杂的软件再用steam-run
。实例:特殊软件包
最后,我演示几种特殊软件的打包。
字体:Hoyo-Glyphs
NixOS 中的字体也是一个个软件包,只要把 TTF 文件放进软件包的
Hoyo-Glyphs 演示,它是一个由米哈游游戏爱好者创建的字体项目,模仿了米哈游的原神、星穹铁道、绝区零等游戏内的架空文字。$out/share/fonts/opentype
文件夹就可以了。{ stdenvNoCC , lib , fetchFromGitHub , ... } @ args: # stdenvNoCC 是一个没有编译器的打包环境,毕竟我们打包字体也用不到编译器 stdenvNoCC.mkDerivation rec { pname = "hoyo-glyphs"; version = "b2bf17cd3d9637fbf55c23bf46fe380e4f7e0739"; src = fetchFromGitHub ({ owner = "SpeedyOrc-C"; repo = "Hoyo-Glyphs"; rev = "b2bf17cd3d9637fbf55c23bf46fe380e4f7e0739"; fetchSubmodules = false; sha256 = "sha256-7Jx/7z3QxAi7lsV3JFwUDWJUpaKOmfZyGKL3MUrUopw="; # 查找所有的 otf 字体文件,复制到 $out/share/fonts/opentype 目录下 # 这样做是因为 hoyo-glyphs 项目中字体散布在多个文件夹下 installPhase = '' mkdir -p $out/share/fonts/opentype/ cp font/**/*.otf $out/share/fonts/opentype/
最后把这个软件包加入 NixOS 的字体配置(或者 Home-Manager 的字体配置),就可以使用了:
hoyo-glyphs = pkgs.callPackage ./hoyo-glyphs.nix { }; fonts.fonts = [ hoyo-glyphsGo 软件包:Konnect
接下来我演示一下 Go 软件包的打包。Nixpkgs 提供了
buildGoModule
函数,可以几乎全自动地给 Go 语言软件打包。但是buildGoModule
存在一个问题:由于 Go 语言程序需要联网下载vendor
目录下的依赖,因此buildGoModule
会计算整个vendor
目录的校验码,这个校验码需要在打包时手动给出。校验码不会算怎么办?老方法,先注释掉(或者随便改几个字),然后构建这个软件包,Nix 会报错,并提示你正确的校验码。
这里我演示的软件是 Konnect,一个 OpenID 单点登录服务,支持 LDAP 后端。
{ fetchFromGitHub , buildGoModule buildGoModule rec { pname = "konnect"; version = "v0.34.0"; src = fetchFromGitHub ({ owner = "Kopano-dev"; repo = "konnect"; rev = "v0.34.0"; fetchSubmodules = false; sha256 = "sha256-y7SD+czD/jK/m0LbFq7qGjwJgBIXfTNrdsA3pzgD2xE="; vendorSha256 = "sha256-ZrwFUZDTbJx5qvloVOa5qK1ykKNkUn1hjfz0xf+8sWk=";
你不需要指定任何的编译命令,
buildGoModule
会自动完成一切。类似的,Python,NodeJS,Rust 等多种语言的项目都有它们对应的打包函数,具体用法可以在 NixOS Wiki 查找。
但是不包括 Java,因为 Java 常用的 Maven 构建系统不支持把依赖固定在某一个版本,因此两次编译的依赖可能会发生变化,违反了 Nix 的初衷。
内核:linux-xanmod-lantian
最后,我演示一下如何自定义 Linux 内核。Nixpkgs 照例提供了方便的
buildLinux
函数:{ pkgs , stdenv , lib , fetchFromGitHub , buildLinux , ... } @ args: version = "5.17.14"; release = "1"; buildLinux { inherit stdenv lib version; src = fetchFromGitHub { owner = "xanmod"; repo = "linux"; rev = "${version}-xanmod${release}"; sha256 = "sha256-OutD9Z/4LMT1cNmpq5fHaJZzU6iMDoj2N8GXFvXkECY="; # 指定内核模块文件夹的名称,我修改了 CONFIG_LOCALVERSION,因此这里要一同修改 modDirVersion = "${version}-xanmod${release}-lantian"; # 从 config.nix 中加载配置 structuredExtraConfig = import ./config.nix args; # 在内核上打的补丁,我这里打了 Nixpkgs 自带的两个补丁,和 patches 目录下的所有补丁(自动检测) kernelPatches = [ pkgs.kernelPatches.bridge_stp_helper pkgs.kernelPatches.request_key_helper ] ++ (builtins.map (name: { inherit name; patch = ./patches + "/${name}"; (builtins.attrNames (builtins.readDir ./patches))); # 只允许给 x86_64 平台打包 extraMeta.broken = !stdenv.hostPlatform.isx86_64;
config.nix
存放你自定义的配置,Nixpkgs 会在 NixOS 默认内核配置的基础上应用你的修改。{ lib, ... }: with lib.kernel; # 指定一个字符串:使用 freeform LOCALVERSION = freeform "-lantian"; # 指定一个数字:也是使用 freeform,把数字写成字符串 LOG_BUF_SHIFT = freeform "12"; # 编译成模块:使用 module # 如果和默认配置冲突,可以用 lib.mkForce 强制应用 TCP_CONG_CUBIC = lib.mkForce module; # 编译进内核:使用 yes TCP_CONG_BBR = yes; # 禁用:使用 no CRYPTO_842 = no;
软件打包一向是困难的,在打包过程中,你往往需要考虑软件的所有依赖,并且调整参数反复尝试。相比于其它发行版,NixOS(以及 Nixpkgs)的打包看起来复杂,但实际上是比较容易的:
大量的重复工作被以函数的形式自动化; 打包环境与主系统隔离,不用担心系统上的残留库文件产生冲突,也不用担心少指定依赖导致其他人无法使用。 本文中我展示了常见的几种打包情况,包括开源软件和闭源软件。但因为我展示的样本很少,无法覆盖到你会遇到的所有情况,因此更多时候还是需要你去自行查阅资料:
NixOS Wiki 上有多种常见编程语言的打包教程,以及一些特殊情况的介绍(例如 Qt)。 Nixpkgs 本身就是一个大型的软件包仓库,存放了 8 万多个软件包的定义,也可以作为参考。