Hillingar - 在 NixOS 上的 MirageOS Unikernels
摘要
这篇博客文章解释了如何通过 Nix 实现 MirageOS Unikernels 的可复现部署,结合函数式编程来构建更安全、更高效的网络应用,如 DNS 服务器。
<p><a href="https://lobste.rs/s/ifyeuo/hillingar_mirageos_unikernels_on_nixos">评论</a></p>
查看缓存全文
缓存时间: 2026/09/04 10:14
# Hillingar 来源:https://ryan.freumh.org/hillingar.html
## 在 NixOS 上部署 MirageOS 单内核
发布日期:2022年12月14日。最后更新:2025年2月15日。
> Hillingar(https://github.com/RyanGibb/hillingar),一种极地的 mirage 效应(https://en.wikipedia.org/wiki/Hillingar_effect)[\[1\]](https://ryan.freumh.org/hillingar.html#ref-lehnNovayaZemlyaEffect1979)
域名系统(DNS)是现代互联网的关键组件,它允许将域名映射到IP地址、邮件服务器等[^1]。这使用户能够使用人类可读的名称访问服务,而无需关心其在互联网上的具体位置。我们可以自行托管DNS服务器,从而获得对域名的权威控制、保护服务器用户的隐私、通过不依赖第三方DNS提供商来提高可靠性,并允许对提供的记录(或服务器本身的行为)进行更高度的定制。
然而,正如我在硕士论文[\[2\]](https://ryan.freumh.org/hillingar.html#ref-gibbSpatialNameSystem2022)中所发现的,可靠且可重复地部署自己的服务器可能颇具挑战性。
Nix部署系统旨在解决这个问题。使用NixOS机器,部署DNS服务器非常简单:
```
{ services.bind = {
enable = true;
zones."freumh.org" = {
master = true;
file = "freumh.org.zone";
};
};
}
```
我们可以用以下命令进行查询:
```
$ dig ryan.freumh.org @ns1.ryan.freumh.org +short
135.181.100.27
```
为了允许用户无需指定域名服务器就能查询我们的域名,我们必须在注册商处创建一个粘性记录,将 `ns1.freumh.org` 指向我们托管DNS机器的IP地址。
您可能会注意到,这个配置运行的是历史悠久的bind2[^2],它是用C语言编写的。作为一种替代方案,使用函数式、高级、类型安全的编程语言来创建网络应用程序,可以极大地提升安全性和易用性,同时保持高效的执行性能(**madhavapeddyMelangeCreatingFunctional2007?**)。其中一种语言就是OCaml。MirageOS[^3]就是这些OCaml程序的部署方式之一[\[3\]](https://ryan.freumh.org/hillingar.html#ref-madhavapeddyUnikernelsLibraryOperating2013)。它不是将它们作为传统的Unix进程运行,而是创建一个专门的“单内核”操作系统来运行应用程序,这允许消除死代码,通过更小的攻击面增强安全性并提高效率。
然而,要使用NixOS部署一个Mirage单内核,必须使用OCaml生态系统原生的命令式部署方法,这消除了Nix提供的可重复系统带来的好处。本文将探讨我们如何通过使用Nix构建它们来实现Mirage单内核的可重复部署。
此时,好奇的读者可能会问,什么是“Nix”?请参阅关于Nix的独立网页(https://ryan.freumh.org/nix.html)了解更多信息。
## MirageOS
MirageOS是一个库操作系统,允许用户创建单内核(unikernels),这是一种专门的操作系统,它将底层操作系统代码和高层应用程序代码集成在单个内核和单个地址空间中[\[3\]](https://ryan.freumh.org/hillingar.html#ref-madhavapeddyUnikernelsLibraryOperating2013)。它是首个此类“单内核创建框架”,但它源自悠久的操作系统研究传统,例如外核(exokernel)库操作系统架构[\[4\]](https://ryan.freumh.org/hillingar.html#ref-englerExokernelOperatingSystem1995)。
将应用程序代码嵌入内核可以实现死代码消除,移除未使用的操作系统接口,从而减少单内核的攻击面并提供更高的效率。[\[3\]](https://ryan.freumh.org/hillingar.html#ref-madhavapeddyUnikernelsLibraryOperating2013)中对比了现有虚拟机设备与单内核独立内核编译方法的软件层差异。
Mirage单内核使用OCaml[^5]编写。OCaml比其他函数式编程语言(如Haskell)更适合系统编程。它支持在必要时回退到不纯的命令式代码或可变变量。
## 部署单内核
现在我们已经理解了Nix和Mirage是什么,并且我们已经明确了在NixOS机器上部署Mirage单内核的动机,那么是什么阻止我们这样做呢?要支持部署一个Mirage单内核(比如DNS服务器),我们需要为它编写一个NixOS模块。我们在Nix表达式中用于在NixOS上部署DNS服务器(§)的bind NixOS模块的一个精简[^6]版本是:
```
{ config, lib, pkgs, ... }:
with lib;
{
options = {
services.bind = {
enable = mkEnableOption "BIND domain name server";
zones = mkOption { ... };
};
};
config = mkIf cfg.enable {
systemd.services.bind = {
description = "BIND Domain Name Server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${pkgs.bind.out}/sbin/named";
};
};
};
}
```
注意对 `pkgs.bind` 的引用。这是Nixpkgs仓库中 `bind` 包的Nix派生(derivation)。回想一下,Nix派生的每个输入本身就是一个Nix派生(§);为了在Nix表达式(即NixOS模块)中使用一个包——即我们需要使用Nix构建该包。一旦我们使用Nix构建了Mirage单内核,我们就可以编写一个NixOS模块来部署它。
## 构建单内核
Mirage使用名为opam[^7]的OCaml包管理器。opam中的依赖项(与编程语言包管理器中常见的一样)有一个文件(除了其他元数据、构建/安装脚本外),指定了依赖项及其版本约束。例如[^8]:
```
...
depends: [
"arp" { ?monorepo & >= "3.0.0" & < "4.0.0" }
"ethernet" { ?monorepo & >= "3.0.0" & < "4.0.0" }
"lwt" { ?monorepo }
"mirage" { build & >= "4.2.0" & < "4.3.0" }
"mirage-bootvar-solo5" { ?monorepo & >= "0.6.0" & < "0.7.0" }
"mirage-clock-solo5" { ?monorepo & >= "4.2.0" & < "5.0.0" }
"mirage-crypto-rng-mirage" { ?monorepo & >= "0.8.0" & < "0.11.0" }
"mirage-logs" { ?monorepo & >= "1.2.0" & < "2.0.0" }
"mirage-net-solo5" { ?monorepo & >= "0.8.0" & < "0.9.0" }
"mirage-random" { ?monorepo & >= "3.0.0" & < "4.0.0" }
"mirage-runtime" { ?monorepo & >= "4.2.0" & < "4.3.0" }
"mirage-solo5" { ?monorepo & >= "0.9.0" & < "0.10.0" }
"mirage-time" { ?monorepo }
"mirageio" { ?monorepo }
"ocaml" { build & >= "4.08.0" }
"ocaml-solo5" { build & >= "0.8.1" & < "0.9.0" }
"opam-monorepo" { build & >= "0.3.2" }
"tcpip" { ?monorepo & >= "7.0.0" & < "8.0.0" }
"yaml" { ?monorepo & build }
]
...
```
每个依赖项都有自己的依赖项及其版本约束。由于我们只能将一个依赖项链接到最终程序中,我们需要解决一组满足这些约束的依赖版本。这并非易事。事实上,这是一个NP完全问题[\[5\]](https://ryan.freumh.org/hillingar.html#ref-coxVersionSAT2016)。
Opam使用Zero Install[^9] SAT求解器进行依赖解析。
Nixpkgs包含许多OCaml包[^10],我们可以将它们作为构建输入提供给Nix派生[^11]。然而,Nixpkgs具有一套全局一致的包版本[^12],[^13]。支持同时安装同一包的多个版本,是因为它们存储在唯一的路径中,可以在需要时单独引用或创建符号链接。因此,使用不同Nixpkgs版本的不同项目或用户不会冲突,但Nix不进行任何依赖版本解析——一切都是固定的[^14]。这对于那些版本约束无法通过Nixpkgs的静态实例满足的opam项目来说是个问题。
幸运的是,Tweag已经有一个项目(`opam-nix`)来处理这个问题[^15],[^16]。该项目在Nix派生内部使用opam依赖版本解析器,然后根据生成的依赖版本创建派生[^17]。
然而,这仍然不支持构建我们的Mirage单内核。单内核通常需要交叉编译:编译为在不同于构建平台的平台上运行。一个常见的目标环境是Solo5[^18],它是一个为单内核设计的沙箱执行环境。它充当一个最小的垫片层,用于在单内核和不同的虚拟机管理程序后端之间进行接口。Solo5使用一个不同的 `glibc`,这需要交叉编译。
Mirage 4[^19]支持使用Dune构建系统[^20]中的工具链进行交叉编译。这使用安装在opam switch(虚拟环境)中的主机编译器(host compiler)作为常规编译器,以及一个目标编译器(target compiler)[^21]。但是包的交叉编译上下文仅在构建时才知晓,因为某些元编程模块可能需要使用主机编译器进行预处理。为了确保使用正确的编译上下文,我们必须向Dune提供我们所有源代码的依赖项。一个名为 `opam-monorepo` 的工具正是为此而创建的[^22]。
我们通过这个拉取请求扩展了 `opam-nix` 项目,以支持 `opam-monorepo` 工作流:https://github.com/tweag/opam-nix/pull/18。然而,这对于使用Nix构建Mirage单内核来说是非常底层的支持。为了提供更好的用户体验,我们还创建了Hillingar Nix flake:https://github.com/RyanGibb/hillingar。它封装了Mirage工具和 `opam-nix` 函数调用,使得一个简单的高级flake可以被放入Mirage项目中,以支持使用Nix构建它。
要为一个单内核添加Nix构建支持,只需:
```
# 从hillingar的默认模板创建flake
$ nix flake new . -t github:RyanGibb/hillingar
# 替换你正在构建的单内核名称
$ sed -i 's/throw "Put the unikernel name here"/""/g' flake.nix
# 为特定目标使用Nix构建单内核
$ nix build .#
```
例如,参见使用Nix将Mirage网站构建为单内核的flake:https://github.com/RyanGibb/mirage-www/blob/master/flake.nix。
## 依赖管理
退一步看大局,我们可以考虑这里涉及的几种不同类型的依赖:
1. **系统依赖**:通过系统包管理器安装的依赖项——在opam术语中称为 `depexts`。对于Hillingar来说,这就是Nix,但其他平台的包管理器包括 `apt`、`pacman` 和 `brew`。对于单内核,这些通常是C库,如 `gmp`。
2. **库依赖**:通过编程语言包管理器安装的依赖项。例如 `opam`、`pip` 和 `npm`。这些是通常具有版本约束并可能需要使用SAT求解器进行解析的依赖项。
3. **文件依赖**:是文件系统级别的依赖项。例如C文件、Java(非内部)类或OCaml模块。这最可能适用于单个项目,但在monorepo中,这些依赖可能跨越许多相互协作的项目(例如Nixpkgs)。这是构建系统通常处理的粒度级别,如Make、Dune和Bazel。
4. **函数依赖**:是函数或语言原生的其他代码单元之间的依赖关系。例如,如果函数 `a` 调用函数 `b`,那么 `a` 就“依赖于” `b`。这是编译器和解释器通常关注的粒度级别。在高阶函数领域,这种依赖关系可能无法提前知晓,但这本质上与构建系统面临的动态依赖问题相同[\[6\]](https://ryan.freumh.org/hillingar.html#ref-mokhovBuildSystemsCarte2018)。
Nix很好地处理了系统依赖,但它没有原生的方式来解析库依赖版本。Opam很好地处理了库依赖,但它没有一种一致的方式来可重复地安装系统包。而Dune处理文件依赖,但不处理其他类型。OCaml编译器在编译和链接程序时跟踪函数依赖。
### 交叉编译
Dune用于支持Mirage单内核的交叉编译(§)。我们使用Dune的DSL中的 `preprocess` 节(stanza)在Dune中编码交叉编译上下文,例如来自 `mirage-tcpip` (https://github.com/mirage/mirage-tcpip/blob/3ab30ab7b43dede75abf7b37838e051e0ddbb23a/src/tcp/dune#L9-L10):
```
(library
(name tcp)
(public_name tcpip.tcp)
(instrumentation (backend bisect_ppx))
(libraries logs ipaddr cstruct lwt-dllist mirage-profile tcpip.checksum tcpip.duration randomconv fmt mirage-time mirage-clock mirage-random mirage-flow metrics)
(preprocess (pps ppx_cstruct)))
```
这告诉Dune使用主机编译器预处理opam包 `ppx_cstruct`。由于此信息仅从构建管理器处获知,这需要获取所有依赖源代码才能使用 `opam-monorepo` 工具支持交叉编译:
> 交叉编译——某些本地代码如何构建的详细信息可能在构建流水线的后期才出现,如果源代码可用,这就不是问题[^23]。
这意味着我们本质上是在构建系统规则中编码编译上下文。为了消除使用 `opam-monorepo` 本地克隆依赖源代码的要求,我们可以尝试将编译上下文编码在包管理器中。然而,预处理可能达到OCaml模块级别的粒度。Dune使用文件依赖处理这个级别的粒度,但opam没有。构建和包管理器之间更紧密的集成可以改善这种情况,就像Rust的Cargo一样。有一些计划旨在模块化opam并与Dune创建更紧密的集成。也有可能使用Nix来避免交叉编译。Nixpkgs的交叉编译[^24]在这里并不能直接帮助我们,因为它只是指定了如何以支持交叉编译的方式打包软件。然而,Nix远程构建器(remote builders)将能够在安装了Nix的远程机器上进行可重复构建[^25],在某些情况下可能绕过对交叉编译的需求。
### 版本解析
Hillingar通过opam使用Zero Install SAT求解器进行版本解析。虽然这可行,但这并不是让Nix与库依赖协同工作的最有原则的方法。一些包管理器只是将Nix用于系统依赖,并像往常一样使用现有工具链处理库依赖[^26]。但通常,`X2nix` 项目数量众多且以*临时(ad hoc)*方式创建。部分原因是处理每种语言生态系统的包仓库系统,并且已有旨在减少代码量的现有方法[^27],[^28]。
相似文章
使用Nix构建系统软件
一篇博客文章,讨论Nix如何帮助解决构建系统软件时的依赖和可重现性问题,特别是针对像BPF和io_uring这样快速演进的子系统。
NNN Stack: NixOS, Niri, Noctalia
NNN Stack 结合了 NixOS、Niri 组合器和 Noctalia shell,创建了一个声明式、可滚动且可复现的桌面环境,并邀请用户贡献他们的 dotfiles。
我喜欢的 NixOS 声明式安装方式
一份关于使用 nixos-anywhere 等工具通过网络声明式安装 NixOS 的指南,重点强调在版本控制下管理配置文件。
在 NixOS 上使用 microvm.nix 的编码代理虚拟机
一篇技术指南,介绍如何在 NixOS 上使用 microvm.nix 创建临时虚拟机,以便在无法访问个人文件的情况下安全运行编码代理。
从 Proxmox 迁移到 NixOS 和 Incus
作者描述了将其家庭实验室从 Proxmox 迁移到使用 Incus 的 NixOS 的过程,强调了声明式配置和可重现性相对于命令式系统的优势。