Using Nix with Dockerfiles

Mitchell Hashimoto

在 Dockerfile 中使用 Nix

Nix 是一个强大的跨平台包管理工具。Nix 的好处涉及方方面面,但其中一个重要好处是,一旦你采用 Nix,就能在开发环境(Linux 和 Mac)、CI 和生产环境中获得一致的环境。

我已经使用 Nix 多年,最近开始结合 Dockerfile 与 Nix 来构建 Docker 镜像。本文将解释这种方法的优势,并通过一个基础示例来展示它的具体形态和使用感受。

抱歉,这不是一篇 Nix 入门文章。你不需要会使用 Nix 也能阅读本文,但我也不会讲解基础的 Nix 概念或介绍 Nix 语言。如果你不了解 Nix,仍然可以阅读本文,并以此判断 Nix 是否值得你花时间去学习。


Dockerfile 很简单,为什么还需要 Nix?

Dockerfile 相当简单——你只需把 shell 命令串起来——所以人们对引入 Nix 这样的工具有所顾虑是完全可以理解的。实际原因是,如果你使用 Nix,就能免费在本地机器、CI、Docker 等环境中获得始终可用的环境1;你几乎不需要重复劳动。

典型的非 Nix 做法是为本地开发、CI 和 Docker(我们将 Docker 视为“生产”环境,因为本文是关于构建 Docker 镜像的)分别投入不同的精力:

  • 对于本地开发,你可能要维护一份冗长的 README,可能会使用 Docker Compose,可能会使用 Vagrant 等。

  • 然后,当你需要在 CI 中运行或测试代码时,你可能又要编写 YAML 定义来重新搭建环境。很多时候,CI 环境的差异大到对 shell 的需求也会略有不同。

  • 最后,对于生产环境,你有一个 Dockerfile,里面又有一套用于构建最终镜像的 shell 命令。

单独来看,这样做还算可以,虽然我仍会觉得有点麻烦。但当把所有这些放在一起时,就会变得非常麻烦且非常脆弱。希望不止我一个人遇到过这样的情况:为某个功能更新了本地开发环境和 Dockerfile,却发现把 CI 弄坏了。或者让开发环境正常工作、在 PR 上拿到了绿色的对勾 ✅,却发现生产运行时已经崩了。

这些问题在使用 Nix 后都会消失。Nix 是你构建和运行软件所需内容的单一可信来源。你更新这一个来源,它在各个地方就基本都能直接生效。你不再需要维护多份关于如何构建和运行软件的描述。

我是认真的,我已经好几年没有遇到过“在我的机器上能跑[但在别的机器上不行]”的问题了。一次都没有。我正在努力克服我的Nix 救世主情结。已经过去太久,以至于当我看到未使用 Nix 的用户抱怨在各种环境中运行软件的问题时,我真的会感到困惑。就像有人望着一条河,抱怨不得不涉水过河,而我正骑着自行车从桥上驶过一样。

这只是其中一个好处,但我认为它是最实用的。Nix 纯粹主义者2会鼓吹其他东西,比如纯粹性、可复现性、强大的语言等等。这些都没错,但我认为真正的痛点是你希望你的环境能够开箱即用。

单就与 Docker 结合使用而言,我承认使用 Nix 大多时候比单独使用 Docker 更难。但当你意识到使用 Nix 所带来的复合收益——能够用同一套配置来增强 CI 和开发环境时,结合 Nix 使用 Docker 就变得比单独使用 Docker更容易,并且还具有众多其他实际好处。

本文将聚焦于 Docker 镜像,因此我不会详细展示如何将同一套配置用于 CI、开发等环境。关于这些主题有大量的博文可供参考。关于本地开发环境,请参阅 Nix 与 Direnv。关于 CI,可以看看我自己的 GitHub Actions 工作流


核心思路

我想先在高层次上解释核心思路,然后再用真实的代码和 shell 命令展示一个具体示例。思路是:

  1. 编写 Nix 代码来描述如何构建和运行你的应用。
  2. 使用 Dockerfile 和官方 Nix 镜像,通过大约一条 shell 命令使用 Nix 来构建你的应用。
  3. 使用多阶段构建 multi-stage build(多阶段构建) 的 FROM scratch 将构建好的应用复制到尽可能小的镜像中。这个最终镜像完全没有安装 Nix——我们只是用 Nix 来构建。

第 1 步是可复用的部分。同样的代码也可以用来构建开发或 CI 环境。如前所述,本文不会对此进行详细介绍。

市面上还有其他“只用 Nix”来构建 Docker 镜像、完全不使用 docker 或 Dockerfile 的博文。那种做法也是可行的,完全没问题,但我想写一篇关于使用 Dockerfile 的文章,因为它可能更让人熟悉、更不让人望而生畏,而且生态系统中的许多工具通常都会接收并使用 Dockerfile。


示例:Python 与 Flask

作为一个真实示例,我将使用 Nix 为运行 Flask Web 应用构建 Docker 镜像。完整代码可在 GitHub 上获取。

Python 应用

我们不是来学 Flask 的,所以你可以在 src/app.py 中查看 Flask 应用代码。它大致如下面的代码块所示。就是 Flask 快速入门中在根路径输出“Hello World”的代码。

from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello_world():
    return "<p>Hello, World!</p>"

Nix Flake

接下来,我们需要创建一个 Nix flake(Nix Flake) 来描述如何构建你的应用。Nix flake 是一种 Nix 环境,用于描述如何创建开发环境、构建软件包等。它类似于 pyproject.tomlCargo.tomlgo.modpackage.json 等,但它是面向 Nix 的。

Nix flake 位于 flake.nix 中,如下所示:

{
  description = "flask-example";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/release-22.11";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let pkgs = import nixpkgs { inherit system; };
      in with pkgs; rec {
        # Development environment
        devShell = mkShell {
          name = "flask-example";
          nativeBuildInputs = [ python3 poetry ];
        };

        # Runtime package
        packages.app = poetry2nix.mkPoetryApplication {
          projectDir = ./.;
        };

        # The default package when a specific package name isn't specified.
        defaultPackage = packages.app;
      }
    );
}

你刚才是不是在让我滚蛋?(漫画在我学会 Nix 之前,每当有人甩出一块 Nix 代码时,我通常就是这种感觉。第一次看到任何不熟悉的代码通常都会让人感到畏惧,再加上 Nix 本身也不是特别美观,但请坚持一下,因为这是你在本文中最后一次看到 Nix 代码,而且它对本文的其余部分基本没有影响。

其中大部分是样板代码。关键部分是带有 devShellpackages.app 的那几行。devShell 会创建一个安装了 Python 和 Poetry 的开发环境。而 packages.app 则描述了如何构建最终的软件包。Nix 对 Poetry 有一流的支持,所以我们可以直接让它构建我们的 Poetry 应用。大多数主流语言都有类似的高层辅助工具,让 Nix 的使用变得更容易。

在你的系统上安装 Nix,你就可以通过运行 nix build 来验证一切是否正常。这应该会构建软件包,你可以在 result/bin/app 运行该应用。

$ nix build
...

$ result/bin/app
 * Serving Flask app 'src.app'
 * Debug mode: off
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit

暂停一下!这其实非常厉害。如果你不熟悉 Nix,刚才发生的事情的惊人之处可能被你忽略了。看看 result/bin/app 是什么(用 cat 查看它)并顺着这个线索探究下去。它是一个运行 Python 应用的脚本,且仅依赖于通过 Nix 安装的软件。它完全不依赖或不与你的本地系统冲突。即使你安装了其他版本的 Python、Flask 等,也完全没有影响;你的应用已被完美打包。

Dockerfile

现在让我们用 Docker 把所有内容整合起来。以下是 Dockerfile

# Nix builder
FROM nixos/nix:latest AS builder

# Copy our source and setup our working dir.
COPY . /tmp/build
WORKDIR /tmp/build

# Build our Nix environment
RUN nix \
    --extra-experimental-features "nix-command flakes" \
    --option filter-syscalls false \
    build

# Copy the Nix store closure into a directory. The Nix store closure is the
# entire set of Nix store values that we need for our build.
RUN mkdir /tmp/nix-store-closure
RUN cp -R $(nix-store -qR result/) /tmp/nix-store-closure

# Final image is based on scratch. We copy a bunch of Nix dependencies
# but they're fully self-contained so we don't need Nix anymore.
FROM scratch

WORKDIR /app

# Copy /nix/store
COPY --from=builder /tmp/nix-store-closure /nix/store
COPY --from=builder /tmp/build/result /app
CMD ["/app/bin/app"]

这是一个多阶段构建。我们首先以基于 nixos/nixbuilder 容器开始。这是仅安装了 nix 的 Nix 官方基础镜像。

在这个构建器中,我们首先运行 nix build

RUN nix \
    --extra-experimental-features "nix-command flakes" \
    --option filter-syscalls false \
    build

这和我们之前做的完全一样。额外的标志是为了确保 Nix 命令可以使用 flakes(它们仍被标记为实验性功能),而 filter-syscalls 选项则让你在 Apple Silicon 上也能交叉编译到 Intel,如果你需要的话。

下一步是:

RUN mkdir /tmp/nix-store-closure
RUN cp -R $(nix-store -qR result/) /tmp/nix-store-closure

这是关键的一步。nix store -qR result 会输出我们的应用所需的所有 Nix 目录的完整列表。具体来说,它是我们应用所需的依赖的 closure(闭包)。我知道这可能让人困惑,所以再换一种说法:它是我们的应用运行所需的最小依赖集合(文件和文件夹),不多也不少。

最后,我们使用 from scratch 容器来构建最终镜像:

FROM scratch

WORKDIR /app

COPY --from=builder /tmp/nix-store-closure /nix/store
COPY --from=builder /tmp/build/result /app
CMD ["/app/bin/app"]

我们将 closure 复制到 /nix/store 中,这确保了我们拥有应用所需的所有依赖。然后我们将 result 软链接复制到 /app。接着,我们将 /app/bin/app 设为入口点。这类似于我们之前在测试 Nix 文件时运行 resuilt/bin/app 的方式。

试一试!

构建并运行 Docker 镜像:

$ docker build -t flask-example:dev .
...

$ docker run --rm flask-example:dev
 * Serving Flask app 'src.app'
 * Debug mode: off
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit

缺点

以这种方式构建 Docker 镜像并没有太多缺点,但出于求真精神,我会尽可能多地列出我能想到的缺点。

最明显的缺点是这需要 Nix 知识。Nix 以不容易学习而闻名。近年来,Nix 的文档有了很大改进,并且还有像 Zero to Nix 这样的额外资源,提供了很大帮助。此外,Nix Installer 方面的状况也已经变得好得多。

考虑到学习 Nix 需要投入一定的时间成本,我认为只有当你计划将 Nix 用于其他功能(如 CI 或开发环境)时,这个缺点才是值得的。在我看来,如果你最终学会了 Nix,这几乎是不可避免的,因为它实在太好用了

另一个缺点是 Docker 镜像中产生的层并不是最优的。单条 RUN nix build 命令会产生一个包含所有依赖的巨大层。从构建时间的角度来看,这非常快,因为几乎所有依赖都会从二进制缓存中下载。但是,这对于缓存来说并不是最优的,基本上每次重新部署都需要让运行时环境重新下载镜像中最大的那一层。

Nix 能够通过使用原生的 Nix dockerTools 来构建镜像,而不是使用 Dockerfile,从而生成更优的 Docker 镜像层,但本文的重点就是向你展示 Dockerfile 的做法。


下一步

我觉得这相当简单。Dockerfile 不到 15 行(不含注释),并且通过使用 Nix,它使用的是与构建开发和 CI 环境完全相同的代码,所以所有逻辑都是共享的。Dockerfile 永远不需要改变。

这使用的是普通的 Dockerfile,因此它能很好地与你可能已经熟悉的工具集成,例如 docker、各种 CI/CD 工具、PaaS 产品等。

最后,你还能获得 Nix 的所有额外好处:你的 Docker 镜像包含运行应用所需的最小文件集,不多不少,Docker 镜像的内容是可复现的(元数据可能会改变镜像本身的哈希值)等等。这些对你来说可能重要,也可能不重要,但它们没有任何缺点,而且你是免费获得的。

如前所述,当你开始将 Nix 配置复用于本地开发和 CI 等其他环境时,这种方法的好处会极大地累积。因此,我建议使用这种方法来增强你现有的 Nix 使用,或将其作为扩展 Nix 使用的入门途径。

脚注

  1. 当然,前期学习 Nix 的成本可以说很高,但我认为回报远高于入门成本。

  2. ❤️ 你们这些朋友,但想触达更广泛的人群!

原文由 Mitchell Hashimoto 发布

本文章由 muse-spark-1.2-contributor 进行翻译