在 Dockerfile 中使用 Nix
原文由 Mitchell Hashimoto 于 發布,訂閱此部落格
Nix 是一套強大的跨平台套件管理工具。Nix 的好處非常多,但其中一個最大的優點是,一旦你開始使用 Nix,就能在開發環境(無論是 Linux 還是 Mac)、CI 以及正式環境中都獲得一致的環境。
我已經使用 Nix 好幾年了,最近開始嘗試用 Dockerfile 搭配 Nix 來建置 Docker 映像檔。這篇文章會說明這種做法的好處,並透過一個基礎範例來展示實際的樣貌與使用體驗。
抱歉,這不是一篇 Nix 入門教學。 你不需要會使用 Nix 也能讀這篇文章,但我也不會在這裡講解 Nix 的基礎概念或介紹 Nix 語言。如果你還不認識 Nix,還是可以讀讀看,並藉此判斷 Nix 是否值得你花時間學習。
Dockerfile 已經夠簡單了,為什麼還要用 Nix?
Dockerfile 其實相當簡單——你只要把一串 shell 指令串起來就好——所以要在其中導入像 Nix 這樣的工具,難免會讓人猶豫。最務實的理由是,如果你用了 Nix,就能在本機、CI、Docker 等各種環境中免費獲得始終可用的環境for free1;幾乎不需要重複投入心力。
典型的非 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 and Direnv。想看 CI 的做法,可以參考我自己的 GitHub Actions workflow。
核心概念
我想先從高層次說明核心概念,然後再用實際的程式碼與 shell 指令來展示範例。概念是這樣的:
- 撰寫 Nix 程式碼來描述如何建置與執行你的應用程式。
- 使用 Dockerfile 和官方的 Nix 映像檔,用大概一行 shell 指令,透過 Nix 來建置你的應用程式。
- 使用多階段建置,以
FROM scratch為基底,把建置好的應用程式複製到盡可能小的映像檔中。這個最終的映像檔完全不需要安裝 Nix——我們只是用 Nix 來完成建置。
步驟一是可以重複使用的部分。同一份程式碼也可以用來建置開發或 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.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 後,你可以執行 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 應用程式的 script,而且只依賴由 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"]這是一個多階段建置。我們首先從 builder 容器開始,它是以 nixos/nix 為基底。這是 Nix 官方的基礎映像檔,裡面就只安裝了 nix。
在這個 builder 中,我們首先執行 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 這個 symlink 複製到 /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 指令會產生一個包含了所有相依套件的巨大圖層。從建置時間的角度來看,這其實非常快,因為幾乎所有的相依套件都會從 binary cache 下載。但這對快取來說並不是最理想的,基本上每次重新部署,你的執行環境都必須重新下載這個最大的圖層。
Nix 其實可以透過原生的 Nix dockerTools 來建置映像檔,產出更佳化的 Docker 圖層,而不是使用 Dockerfile,但這篇文章的重點就是要向你展示 Dockerfile 的做法。
接下來
我覺得這還滿簡單的。這個 Dockerfile 不含註解不到 15 行,而且因為使用了 Nix,它用的正是你用來建置開發與 CI 環境的同一份程式碼,所以全部都是共用的邏輯。Dockerfile 永遠不需要再變動。
這用的是一般常見的 Dockerfile,所以能非常好地與你可能已經熟悉的各種工具整合,例如 docker、各種 CI/CD 工具、PaaS 服務等等。
最後,你還能獲得 Nix 帶來的所有額外好處:你的 Docker 映像檔只包含執行應用程式所需的最小檔案集合,不多也不少,而且 Docker 映像檔的內容是可重現的(雖然詮釋資料可能會改變映像檔本身的雜湊值)等等。這些對你來說或許重要,或許不重要,但它們沒有任何壞處,而且是免費附送的。
如我稍早所說,當你開始把 Nix 設定重複使用在其他環境,例如本機開發和 CI 時,這種做法的好處會大幅疊加。因此,我會建議你用這種方式來強化你現有的 Nix 使用經驗,或是把它當作擴大使用 Nix 的入門途徑。
註腳
隨機一篇部落格
留言
登入後參與討論