在 Dockerfile 中使用 Nix
原文由 Mitchell Hashimoto 于 发布,订阅该博客
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 一起用,多数情况下要比单独用 Docker 更复杂。但当你意识到可以用同一份配置来增强 CI 和开发环境时,Nix 带来的复利效应就会显现,此时再将 Nix 与 Docker 结合,反而会比单独使用 Docker 更简单,并且还能带来许多其他实际好处。
本文将聚焦于 Docker 镜像,因此不会详细展示如何将同一份配置用于 CI、开发环境等。关于这些主题已经有大量博文可供参考。关于本地开发环境,可以查看 Nix 与 Direnv。关于 CI,可以看看我自己的 GitHub Actions 工作流。
核心思路
我想先在较高层面解释一下核心思路,然后再通过代码和 shell 命令给出一个真实示例。思路如下:
- 编写 Nix 代码来描述如何构建和运行你的应用。
- 使用 Dockerfile 和官方 Nix 镜像,通过大约一条 shell 命令用 Nix 构建应用。
- 使用
FROM scratch的多阶段构建,将构建好的应用复制到尽可能小的镜像中。最终镜像完全不需要安装 Nix——我们只是用 Nix 来完成构建。
第一步是可复用的部分。同一份代码也可用于构建开发或 CI 环境。如前所述,本文不会对此展开详述。
市面上还有其他“ Nix 与 Docker ”相关的博文,它们完全只用 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 环境。它类似于 pyproject.toml、Cargo.toml、go.mod 或 package.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 代码,而且它对后文的影响基本可以忽略不计。
其中大部分都是样板代码。关键部分是包含 devShell 和 packages.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/nix 的 builder 容器开始。这是 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 目录列表。具体来说,它是应用所需的依赖闭包。我知道这可能有点让人困惑,换句话说:它是应用运行所需的最小依赖集合(文件和文件夹),不多也不少。
最后,我们使用 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"]我们将闭包复制到 /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 其实可以通过原生的 dockerTools 来构建镜像而非使用 Dockerfile,从而生成更优的镜像分层,但本文的重点正是向你展示基于 Dockerfile 的做法。
下一步
我觉得这相当简单。Dockerfile 只有不到 15 行(不算注释),而且通过使用 Nix,它复用了你用于构建开发和 CI 环境的完全相同的代码,因此所有逻辑都是共享的。Dockerfile 本身永远不需要改动。
由于使用的是普通的 Dockerfile,它能很好地与你可能已经熟悉的工具集成,例如 docker、各种 CI/CD 工具、PaaS 平台等。
最后,你还能获得 Nix 带来的所有额外好处:Docker 镜像只包含运行应用所需的最小文件集合,不多不少;镜像内容是可复现的(元数据可能会改变镜像本身的哈希值)等等。这些对你来说或许重要,或许不重要,但它们没有任何坏处,完全是免费附赠的。
正如我前面所说,当你开始将 Nix 配置复用于本地开发和 CI 等其他环境时,这种做法的好处会极大地累积。因此,我建议你采用这种方式来增强现有的 Nix 使用,或是将其作为扩展 Nix 使用的切入点。
脚注
随机一篇博客
评论
登录后参与讨论