Using Nix with Dockerfiles

Mitchell Hashimoto

透過 Dockerfile 使用 Nix

Nix 是一套強大的跨平台套件管理工具。Nix 的優點非常廣泛,但其中一大好處是,一旦採用 Nix,就能在開發環境(包含 Linux 與 Mac)、CI 與正式環境中獲得一致的環境。

我已經使用 Nix 多年,最近開始搭配 Nix 與 Dockerfile 來建置 Docker 映像檔。本文將說明這種做法的好處,並透過一個基本範例展示實際的外觀與操作感受。

抱歉,這不是一篇 Nix 入門文章。閱讀本文不需要懂得如何使用 Nix,但我也不會在此說明 Nix 的基本概念或介紹 Nix 語言。如果你不了解 Nix,仍然可以閱讀本文,並藉此判斷 Nix 是否值得你學習。


Dockerfile 已經很簡單,為什麼還要用 Nix?

Dockerfile 相當簡單——只要把 shell 指令串起來就行——因此對於要在其中引入 Nix 這樣的工具,難免會有所猶豫。實際的原因在於,如果你使用 Nix,就能在本機、CI、Docker 等環境中免費獲得始終可用的環境(for free)1;幾乎不需要重複投入心力。

典型、未使用 Nix 的做法,是為本地開發、CI 與 Docker(由於本文主題是建置 Docker 映像檔,我們將 Docker 稱為「正式」環境)分別投入各自的建置心力:

  • 在本地開發方面,你可能有一份龐大的 README,可能使用 Docker Compose,也可能使用Vagrant 等工具。

  • 接著,當你需要在 CI 中執行或測試程式碼時,可能又得撰寫 YAML 定義來重新設定環境。很多時候,CI 環境的差異大到會產生略微不同的 shell 需求。

  • 最後,在正式環境方面,你又有一個 Dockerfile,裡面有自己的一組 shell 指令來建置最終映像檔。

單獨來看,這樣做還算可以,雖然我認為仍然有點麻煩。但當你把全部加在一起時,就會變得非常麻煩且脆弱。我希望不只有我曾為了某個功能更新了本地開發環境與 Dockerfile,卻發現把 CI 弄壞了。或是讓開發環境正常運作、PR 上出現綠色勾勾 ✅,才發現正式環境的執行階段已經壞掉。

這些問題在使用 Nix 後都會消失。Nix 是你建置與執行軟體所需內容的單一真實來源(single source of truth)。你只要更新這個單一來源,它在各處通常就能直接運作。你不再需要維護多份關於如何建置與執行軟體的描述。

我是認真的,我已經有好幾年沒遇過「在我的電腦上可以跑[但在別的地方不行]」的問題。一次都沒有。我正努力克服自己的Nix God complex(Nix 上帝情結)。已經過了太久,以至於當我看到未使用 Nix 的使用者抱怨在各種環境中執行軟體的問題時,我真的感到困惑。這就像有人看著一條河,哀嘆必須涉水而過,而我卻是騎著腳踏車從橋上經過。

這只是其中一個好處,但我認為是最實用的一個。Nix 的純粹主義者2會強調其他優點,例如純粹性、可重現性、強大的語言等。這些都沒錯,但我認為真正的痛點在於你希望你的環境就是能用。

單獨來看,我認為搭配 Docker 使用 Nix,大多時候只會比單獨使用 Docker 更難。但若意識到使用 Nix 還能以相同設定來強化 CI 與開發環境的複合效益,那麼搭配 Docker 使用 Nix 就會變得比單獨使用 Docker更簡單,而且還有許多其他實務上的好處。

本文將專注於 Docker 映像檔,因此不會詳細展示如何將相同設定用於 CI、開發環境等。關於這些主題已有許多部落格文章可參考。關於本地開發環境,請參閱Nix and Direnv。至於 CI,請參考我自己的 GitHub Actions 工作流程


核心概念

我想先從較高的層次說明核心概念,然後再透過實際的程式碼與 shell 指令來展示範例。概念如下:

  1. 撰寫 Nix 程式碼來描述如何建置與執行你的應用程式。
  2. 使用 Dockerfile 與官方 Nix 映像檔,透過約一條 shell 指令以 Nix 建置應用程式。
  3. 使用 multi-stage build(多階段建置)的 FROM scratch,將已建置好的應用程式複製到盡可能小的映像檔中。這個最終映像檔完全沒有安裝 Nix——我們只是用 Nix 來建置。

步驟 1 是可重複使用的部分。同一份程式碼也可用來建置開發或 CI 環境。如前所述,本文不會對此深入說明。

市面上還有其他「Nix 與 Docker」部落格文章是只用 Nix 來建置 Docker 映像檔,完全不使用 docker 或 Dockerfile。那樣做是可行且完全沒問題的,但我想撰寫關於使用 Dockerfile 的做法,因為它對大多數人來說可能更熟悉、較不讓人卻步,而且生態系中有許多工具本來就會讀取並使用 Dockerfile。


範例:Python 與 Flask

作為實際範例,我將使用 Nix 來建置一個用於執行Flask 網頁應用程式的 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.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"]

這是一個multi-stage build。我們首先從以 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 目錄清單。具體來說,它是只有我們的應用程式所需的相依套件的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"]

我們將封閉集合複製到 /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 進行翻譯