NixOps 入门介绍

NixOps is a tool for deploying to NixOS machines in a network or the cloud.

最近尝试用 NixOps 来管理个人的服务器,整体的的感受和 Nix 生态的其它工具一样,上手比较麻烦,文档很简陋,但是跑通之后体验很好。

下面记录一下从头开始配置一个受 NixOps 控制的服务器的流程。

创建 NixOS 虚拟机

首先在 VMWare ESXi 上面创建一个 NixOS 系统的虚拟机,如果使用 minimal 镜像的话需要第一次启动进 livecd 环境之后手动分区安装(参考 NixOS Manual );或者用带 gui 的镜像,它们是支持图形化安装向导的,可以选择不安装桌面环境。 建议用后一种方式。

还有一点需要注意,目前的 NixOS 镜像不支持 UEFI secure boot ( 相关 issue ),因此只能以 BIOS 或者关闭 secure boot 的 UEFI 模式引导。

在公有云上使用 NixOS 可以参考 NixOS friendly hosters

机器初始配置

使用 NixOps 管理服务器的前提是本地能够以 root 身份 ssh 连接到对应的机器,因此需要先在服务器上配置好 sshd ,步骤:

  1. 切换为 root 用户
  2. nano /etc/nixos/configuration.nix 去掉 services.openssh.enable = true; 一行的注释
  3. nixos-rebuild --switch 使配置生效
  4. 将公钥添加到 /root/.ssh/authorized_keys
  5. 在本地测试 ssh root@host 确认可以连接

备注: 也可以用非 root 用户通过 NixOps 部署,不过属于不必要的麻烦。

本地配置

安装 NixOps

目前 NixOps 1 部分依赖有安全风险,尝试了开发中的 NixOps 2 一切顺利,建议安装 NixOps 2 ,它在 nixpkgs 中的包名是 nixops_unstable 。 NixOps 相关的资料并不多,几个重要的来源:

NixOps with Flakes

安装完毕后就可以通过 nixops 命令来执行相关的操作了。 NixOps 首先会寻找对应的配置文件来确定执行操作的目标,查找的路径是当前目录下的 flake.nix 或者 nixops.nix ,明确指定配置文件的能力在 2.0 的计划中。 下面以使用 flake 文件的情况为例说明。

NixOps 使用 flake 输出的 nixopsConfigurations 属性。 一个样例 flake.nix 文件:

{
  description = "An example NixOps configuration";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-22.05";
    home-manager = {
      url = "github:nix-community/home-manager/release-22.05";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { self, nixpkgs, ... }@inputs: {
    nixopsConfigurations.default = {
      inherit nixpkgs;
      network = {
        description = "personal environment";
        storage.memory = { };
      };
      homeserver = import ./hosts/server/configuration.nix inputs;
    };
  };
}

nixopsConfigurations.default 有两个必备属性: nixpkgsnetwork.storage 。 前者无需赘述,后者指定了 NixOps 存储状态的位置, legacy 表示在 ~/.nixops/ 下存储相关的配置文件和 sqlite 数据库, memory 表示没有特殊状态需要存储。 nixops 在第一次连接某个机器的时候会自动生成一个 SSH 密钥对,这在管理大规模集群的时候非常方便,但是需要 legacy 类型来提供密钥存储等能力。 对于服务器比较少的场景,确保本机能够 ssh 到对应服务器并关闭自动生成功能,使用 memory 存储带来的心智负担更少。

之后就是每台机器的名字到配置的键值对。 这里的配置是原本的 NixOS 配置 外加一些 NixOps 特定的配置属性。 前面完成服务器上的 NixOS 安装之后,我们需要把服务器上的配置文件复制到本地的 flake 仓库中,这包括了 /etc/nixos/configuration.nix/etc/nixos/hardware-configuration.nix ,并在 flake.nix 里 import ,这一步在上面的样例中有体现。 下面是一个样例 configuration.nix 文件,在原本的 NixOS 配置的基础上添加了 flake 参数传入(以 home-manager 为例)和 NixOps 相关配置:

{ home-manager, ... }@inputs:
{ config, pkgs, modulesPath, ... }:
{
  # nixops machine property
  deployment = {
    targetHost = "192.168.2.143";
    name = "homeserver";
    uuid = "6edf29a9-f665-401a-9786-7fa3eca9986a";
    provisionSSHKey = false;
  };

  imports =
    [
      # Include the results of the hardware scan.
      ./hardware-configuration.nix
      home-manager.nixosModules.home-manager
    ];

  virtualisation = {
    ...

如果没有提供 deployment.uuid ,在第一次部署时会自动生成并依照 storage 配置存储,也可以手动指定。 deployment.provisionSSHKey 默认为 true ,即默认为每个新机器提供 ssh 密钥对,这里我们不使用存储而是用本机的 ssh 直连,因此设置为 false 。

一切配置完成后,就可以执行 nixops deploy 来把当前的配置部署到远程机器了。 可以通过 nixops listnixops info 查看当前的状态,更多指令参考上文的相关资料。

一些服务配置

下面结合我个人的一些使用场景介绍各种类型的服务部署方式。 下面的内容与 NixOps 无关,主要是通过 NixOS 配置文件来完成各种服务的部署,因为目的相同所以放在这里。

OCI 容器

首先是 Docker 镜像。 在使用 Nix 之前可以通过 bash 脚本或者 docker-compose 声明文件记录对应的配置,并通过 restart=unless-stopped 确保服务一直运行,但是这种方式下服务的启动和维护仍然需要手动执行对应的脚本来完成。 通过 NixOS 我们可以实现声明式的描述 OCI 容器的配置以及自动部署,一个样例:

  virtualisation = {
    docker.enable = true;
    oci-containers = {
      backend = "docker";
      containers = {
        postgres = {
          autoStart = true;
          image = "postgres:14";
          ports = [ "2345:5432" ];
          volumes = [ "/home/sine/data:/var/lib/postgresql/data" ];
          environment = {
            POSTGRES_DB = "db";
            POSTGRES_USER = "user";
            POSTGRES_PASSWORD = "pw";
          };
        };
      };
    };
  };

Systemd 服务

有些服务用 Docker 打包会过于繁琐,或者有一些详细的配置 Docker 无法满足,这时可以借助 systemd 来为我们做服务进程的管理。 相比于直接手写 systemd 服务配置文件的方式, nix 可以更好的组织所有的服务配置,同时管理相关的依赖。 以一个服务为例:

  systemd.services.fava = {
    enable = true;
    description = "the web ui for beancount";
    serviceConfig = {
      WorkingDirectory = "/home/sine/code/neon";
      ExecStart = "${pkgs.nix}/bin/nix develop -c fava beans/base.bean -H 0.0.0.0 -p 2025";
      User = "sine";
    };
    wantedBy = [ "multi-user.target" ];
    path = [
      pkgs.git
    ];
  };

systemd.services 由服务名到服务配置的映射组成。 其中的 serviceConfigwantedBy 对应于 systemd 配置文件的相关字段,这里不再展开。 我们指定了以用户身份进入项目文件夹,入口命令 nix develop -c 会以工作目录下的 flake.nix 配置声明的环境(其中包含了fava)执行指令 fava beans/base.bean -H 0.0.0.0 -p 2025 ,启动该服务。 ${pkgs.nix} 会被替换为 nix 包的实际绝对路径。 由于 nix 命令实际执行时还需要调用 gitgit 也作为一个依赖添加进 path 配置项中。

部署完成后,可以在服务器上通过 systemctl status fava 来查看服务状态,以及通过 journalctl -u fava 查看相关的日志。

本地仓库打包上传

上一节的 systemd 服务实际运行时还依赖对应的仓库文件,这需要在服务启动之前在服务器上准备好。 我们可以把对应的仓库作为一个包一起部署到服务器上,进一步增加自动化的程度。

第一步是给要部署的项目打包,我这里的项目只涉及动态语言,因此可以直接源码发布。 给 flake.nix 的输出部分添加如下属性:

legacyPackages.neon = pkgs.stdenv.mkDerivation {
  name = "neon";
  src = self;
  buildPhase = " "; # prevent default make command and do nothing
  installPhase = "cp -r . $out";
};

之后执行 nix build .#neon ,确定输出文件夹中的内容是当前项目的源码。 需要注意的一点是这里不能使用 flake 的 outputs.packages 属性,因为这种情况下的包不允许引用其他包(src = self;),因此只能声明在 outputs.legacyPackages相关讨论

第二步是把当前包添加为 NixOS 配置的依赖。 首先是在 flake.nix 文件中添加仓库为输入源,写法:

neon = {
  url = "git+ssh://git@github.com/username/repo";
  inputs.nixpkgs.follows = "nixpkgs";
};

由于 nixops deploy 无法更新 flake.lock 文件,如果有新的 input 会报错。 我们需要手动更新单个依赖版本 nix flake lock --update-input neon

但是更新的时候失败了,原因是 Nix 目前不支持私有仓库使用 Git LFS , 相关 issue 。 如果没有使用 LFS 可以继续,这里我们改为使用本地目录:

neon = {
  url = "git+file:///home/path/to/repo";
  inputs.nixpkgs.follows = "nixpkgs";
};

之后更新依赖,成功。 下一步就是在相关的地方使用我们的依赖了。 以上面的 fava 服务为例,我们需要这个项目的根文件夹作为工作目录 WorkingDirectory = "/home/sine/code/neon"; ,之前是手动把这个仓库 clone 到对应的本地目录的,现在,我们可以直接把它指定为 WorkingDirectory = inputs.neon.legacyPackages.x86_64-linux.neon; ,省去了每次更新仓库的麻烦。

同时,我们也可以把这个包的目录链接到想要的位置来方便访问。 例如当前用户的家目录:

home-manager = {
  users.sine = {
    home.file."secret-folder/neon".source = inputs.neon.legacyPackages.x86_64-linux.neon;
  };
};

或者 /etc 下面:

environment.etc."secret-folder/neon".source = inputs.neon.legacyPackages.x86_64-linux.neon;

总结

目前基于 OCI 容器的部署方案和相关的生态系统已经很成熟了, NixOps 在这方面无法与之相比。 但是 NixOps 有一些独特的侧重点,很好的解决了一些 K8S 等工具无力触及的问题:

对机器底层状态的管理, NixOps 的配置是基于 NixOS 配置文件的,能够很好的描述机器级别的状态,例如硬盘挂载、网络配置、用户权限。 相比之下, K8S 只是在机器上安装的一个软件,没有办法对自己所在节点的状态做管理。

对软件的详细配置, 同样是借助于整个 Nix 生态,我们可以通过 NixOS 以及 home-manager 等工具对运行在机器上的软件做很详细的配置,比如服务器上用户的 git name 和 email 。 一些服务也可以不经过 docker 虚拟化就能在服务器上可靠的分发和运行,这给了我们比 docker 镜像更细粒度的软件配置能力。

综上所述,裸金属层面的集群管理,不需要或者不方便用 Docker 分发的小服务或者状态的管理是 NixOps 比较典型的使用场景。 NixOps 的配置文件是按照机器划分的,关注对每一台机器的配置,而 K8S 的配置是按照服务划分的,关注一个服务的可用性。 我们可以以此为根据判断使用哪一种工具。


最后还有一个悬而未决的问题,在 configuration.nix 中指定的 TUNA 镜像源 nix.settings.substituters = [ "https://mirrors.tuna.tsinghua.edu.cn/nix-channels/store" ]; 看起来 NixOps 在服务器上安装包的时候并没有生效,但是服务器上的 Nix 命令有使用 TUNA 源,应该和源码中这里的指令配置有关,没有进一步调查。