Per-Project Development Environments with Nix

Michael Lynch

使用 Nix 打造以專案為單位的開發環境

Nix 是一款功能廣泛但學習曲線陡峭的產品。從安裝單一套件到管理作業系統上的每一個檔案與應用程式,它都能勝任。

即使是完全的新手,你也能用 Nix 做到一件很實用的事,那就是管理開發環境。

Nix 讓我能在同一套系統上擁有多個專案,每個專案對可用依賴套件都有各自獨立的視圖。我可以讓一個使用 Python 2.7 與 Node.js 4.x 的舊專案,與一個使用 Python 3.11 與 Node.js 20 的新專案並存,彼此互不干擾。

即使你完全沒有 Nix 經驗,只要花大約 20 分鐘,也能用上由 Nix 管理的開發環境。

注意:我仍是 Nix 新手,不確定自己的做法是否為最佳解。如果有經驗更豐富的 Nix 讀者有改進建議,歡迎告訴我,我會更新這篇文章。

為何不用 Docker 管理開發環境?

我喜歡 Docker,也會用它來做部署與某些 DevOps 任務,但我發現它對管理開發環境沒有什麼幫助。

我都是透過 SSH 在 VS Code 中進行開發,而 Docker 讓這件事變得很麻煩。我知道有變通方法,但從來不覺得它們好用。

為何不用 Ansible 管理開發環境?

過去六年來,我都是用 Ansible 管理開發環境,效果還算可以。

對於每個軟體專案,我都會建立一台專屬的虛擬機器,並搭配對應的 Ansible playbook 來設定虛擬機器上的所有依賴套件。

問題在於,當我只想花幾分鐘試驗某個東西時,我實在不太想為了它就啟動一整台 VM、編寫 playbook,然後再等 10 到 20 分鐘讓 Ansible 完成伺服器佈建。

我正慢慢將所有專案從 Ansible 遷移到 Nix,因為 Nix 輕量得多。透過 Ansible 升級依賴套件,通常每個套件要花我約 20 分鐘,而用 Nix 做同樣的事大約只要兩分鐘。

建立一個簡單的 Nix 開發環境

為了示範 Nix 開發環境的運作方式,我將從一個什麼都沒安裝的 Debian 11 系統開始。

安裝 Nix

首先,安裝 Nix。我使用的是第三方 Determinate Systems 安裝程式,而非官方的 Nix 安裝程式,因為 Determinate Systems 的版本做了一些對本文示範很有幫助的預設設定。

curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install && . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh

建立一個簡單的 Python 2.7 應用程式

為了展示 Nix 開發環境的實際運作,我將建立一個在 Python 2.7 上執行的簡單應用程式,這是已於 2020 年正式棄用的舊版 Python。

首先,我為專案建立一個新目錄。

mkdir example && cd example

接著,下載或複製以下 Nix flake,這是定義 Nix 開發環境的檔案:

{
  description = "Demo Nix dev environment";

  inputs = {
    flake-utils.url = "github:numtide/flake-utils";

    # 2.7.18.7 release
    python-nixpkgs.url = "github:NixOS/nixpkgs/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9";
  };

  outputs = {
    self,
    flake-utils,
    python-nixpkgs,
  } @ inputs:
    flake-utils.lib.eachDefaultSystem (system: let
      python-nixpkgs = inputs.python-nixpkgs.legacyPackages.${system};
    in {
      devShells.default = python-nixpkgs.mkShell {
        packages = [
          python-nixpkgs.python2
        ];

        shellHook = ''
          python --version
        '';
      };
    });
}

下載 flake.nix

curl --show-error --fail https://mtlynch.io/notes/nixos-dev-environment/flake.nix > flake.nix

如果你不熟悉 Nix,flake.nix 檔案看起來可能充滿令人困惑的語法,但其實大多只是簡單的樣板程式碼。我會在下方更詳細地說明。

最後,該啟動我的 Nix 開發環境了。請注意,第一次執行這個指令時,會花幾分鐘初始化所有東西,但之後的啟動只需幾秒鐘就能完成。

# We need NIXPKGS_ALLOW_INSECURE and --impure because Python 2.7 is past end of
# life.
$ NIXPKGS_ALLOW_INSECURE=1 nix develop --impure
Python 2.7.18.7

成功了!我已經有可用的 Python 2.7 環境。

請注意,我並沒有在這個特定 Nix 環境之外安裝 Python 2.7。如果我開啟一個新終端機而不執行 nix develop,會看到以下 Python 未安裝的錯誤訊息:

$ python  --version
-bash: python: command not found

回到我的 Python 2.7 Nix 環境,讓我試著用那種在 Python 3 中已無法運作、被視為禁忌且已棄用的 print 語法來執行一個簡單的 Python 腳本:

$ echo 'print "hello, world!"' > main.py && python main.py
hello, world!

太好了!我在這個環境中可以執行舊版的 Python 2.7 程式碼。

尋找版本字串

那麼,我的 flake.nix 檔案是如何運作的呢?

flake.nix 檔案中最前面的幾行,宣告了我想要的 Python 套件的確切版本:

{
  # 2.7.18.7 release
  python-nixpkgs.url = "github:NixOS/nixpkgs/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9";

# 2.7.18.7 release 這一行只是給我自己參考的註解,Nix 會忽略它。真正發揮作用的是 python-nixpkgs 這一行。

NixOS/nixpkgs 是一個 GitHub 儲存庫,而 517501bcf14ae6ec47efd6a17dda0ca8e6d866f9 是該儲存庫中 python2 套件對應到 Python 2.7.18.7 的版本。

我怎麼知道那串長長的版本字串?我是透過 Nixhub 查到的。

顯示搜尋對話框的 NixHub 首頁截圖

Nixhub 是由 Jetpack 建立的免費套件搜尋服務,這是一家在 Nix 之上銷售開發者工具的公司。

Nixhub 是在三個月前才發布的,它讓我在 Nix 上的工作輕鬆許多。如果我想查找某個套件特定版本的雜湊值,只要在 Nixhub 上搜尋,就能找到對應的 commit ID。

所以,為了找到 Python 2.7.18.7 的版本字串,我在 Nixhub 上搜尋 python,然後在結果清單中往下捲動,找到最新的 Python 2.7.x 版本:

NixHub 搜尋結果截圖,顯示人類可讀的版本字串在最上方,其次是 nixpkgs 版本字串,最後是套件名稱

NixHub 讓我能將人類可讀的版本字串轉換為 nixpkgs 參照與套件名稱。

老實說,鎖定確切的套件版本非常麻煩。我希望 Nix 工具鏈未來能演進到讓你只需指定想要版本 2.7.18.7,而不是得繞一大圈去查找對應版本的 git commit 雜湊值。但就目前而言,這是我所知鎖定版本的最佳方法。

了解 flake.nix 檔案

好,我說過會更詳細地說明上面展示的 flake.nix 檔案。

我不會解釋關於 Nix flakes 的所有內容,因為我自己也沒有深入的理解。我只會說明建立你自己的開發環境所需的最基本知識。若想更深入了解 Nix flakes,請參閱「Practical Nix Flakes(《實用 Nix Flakes》)」

inputs 區段是用來放置你想在環境中使用的不同 Nix 來源版本。我使用的是針對 GitHub 儲存庫的特殊語法,但你也可以從其他來源儲存庫或 URL 匯入。

{
  inputs = {
    flake-utils.url = "github:numtide/flake-utils";

    # 2.7.18.7 release
    python-nixpkgs.url = "github:NixOS/nixpkgs/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9";
  };

devshells.default 定義了 Nix shell 的開發環境。packages 則包含了我想在環境中使用的所有套件清單。

{
    devShells.default = python-nixpkgs.mkShell {
        packages = [
          python-nixpkgs.python2
        ];

對於大多數套件,套件名稱並不包含版本。像 htopvim 這類套件,名稱永遠相同,但某些套件(例如 Python)在同一個 Nixpkgs 版本中會提供多個版本,因此你必須指定 python2 而非 python,以避免與 Python 3 混淆。

最後一個相關的部分是 shellHook 區段。

{
  shellHook = ''
    python --version
  '';

Nix 會在將你帶入 shell 之前,先執行 shellHook 中的指令。你可以在那裡放置任何 shell 指令。

我喜歡在那裡放置會印出依賴套件版本的指令,這樣就能輕鬆確認我的 Nix flake 是否正常運作。

升級到 Python 3

好,假設我已經準備好要下功夫,將我那只有一行的 Python 應用程式從 Python 2.7 移植到新版的 Python 3。我只需要更新以下兩個片段:

{
    # 3.12.0 release
    python-nixpkgs.url = "github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10";
{
    packages = [
      python-nixpkgs.python312
    ];

我的新 Python 3 flake 如下所示:

{
  description = "Demo Nix dev environment";

  inputs = {
    flake-utils.url = "github:numtide/flake-utils";

    # 3.12.0 release
    python-nixpkgs.url = "github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10";
  };

  outputs = { self, flake-utils, python-nixpkgs }@inputs :
    flake-utils.lib.eachDefaultSystem (system:
    let
      python-nixpkgs = inputs.python-nixpkgs.legacyPackages.${system};
    in
    {
      devShells.default = python-nixpkgs.mkShell {
        packages = [
          python-nixpkgs.python312
        ];

        shellHook = ''
          python --version
        '';
      };
    });
}

我按下 Ctrl+D 或輸入 exit 來離開原本的 Nix shell,然後執行以下指令來初始化新的 Python 3 環境:

$ nix develop
warning: updating lock file '/home/mike/example/flake.lock':
• Updated input 'python-nixpkgs':
    'github:NixOS/nixpkgs/517501bcf14ae6ec47efd6a17dda0ca8e6d866f9' (2023-09-27)
  → 'github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10' (2023-10-03)
Python 3.12.0

方便的是,由於新版 Python 不被視為不安全,我可以省略先前為了 Python 2.7 所需的 NIXPKGS_ALLOW_INSECURE 選項。

我現在應該已經處於 Python 3 環境中。為了證明這一點,我來試著執行 Python 2 風格的 main.py,看看 Python 3 是否會如預期般發出驚恐的尖叫:

$ python main.py
  File "/home/mike/example/main.py", line 1
    print "hello, world!"
    ^^^^^^^^^^^^^^^^^^^^^
SyntaxError: Missing parentheses in call to 'print'. Did you mean print(...)?

看起來 Python 3 正如預期般運作。我來把語法更新為 Python 3 並再試一次:

$ echo 'print("hello, world!")' > main.py && python main.py
hello, world!

一切又恢復正常了。我只是改了 Nix flake 中的幾行,就把環境從 Python 2.7 更新到了 Python 3.12!

新增一個依賴套件

好,我已經示範如何更新套件,那要如何新增一個依賴套件呢?

我將新增一個會自動執行 Python 檔案的 bash 腳本。

(cat <<EOF
#!/usr/bin/env bash

set -eux

readonly MAIN_SCRIPT="main.py"
python $MAIN_SCRIPT
EOF
) > run.sh && chmod +x run.sh && ./run.sh

你應該會看到以下輸出:

+ readonly MAIN_SCRIPT=main.py
+ MAIN_SCRIPT=main.py
+ python main.py
hello, world!

我不太擅長寫 bash,所以靜態分析工具應該能幫我改進 run.sh 腳本。

shellcheck 是一款優秀的 bash 腳本 linter,我在任何撰寫 bash 程式碼的地方都會使用它。我想把 shellcheck 拉進我的開發環境,讓它幫我找出潛在的 bash 陷阱,因此我更新了 Nix flake:

{
  description = "Demo Nix dev environment";

  inputs = {
    flake-utils.url = "github:numtide/flake-utils";

    # 3.12.0 release
    python-nixpkgs.url = "github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10";

    # 0.9.0 release
    shellcheck-nixpkgs.url = "github:NixOS/nixpkgs/8b5ab8341e33322e5b66fb46ce23d724050f6606";
  };

  outputs = { self, flake-utils, python-nixpkgs, shellcheck-nixpkgs }@inputs :
    flake-utils.lib.eachDefaultSystem (system:
    let
      python-nixpkgs = inputs.python-nixpkgs.legacyPackages.${system};
      shellcheck-nixpkgs = inputs.shellcheck-nixpkgs.legacyPackages.${system};
    in
    {
      devShells.default = python-nixpkgs.mkShell {
        packages = [
          python-nixpkgs.python312
          shellcheck-nixpkgs.shellcheck
        ];

        shellHook = ''
          python --version
          echo "shellcheck" "$(shellcheck --version | grep '^version:')"
        '';
      };
    });
}

同樣地,我按下 Ctrl+D 或輸入 exit 離開原本的 Nix shell,然後用以下指令建立新的 shell:

$ nix develop
warning: updating lock file '/home/mike/example/flake.lock':
• Added input 'shellcheck-nixpkgs':
    'github:NixOS/nixpkgs/8b5ab8341e33322e5b66fb46ce23d724050f6606' (2023-09-19)
Python 3.12.0
shellcheck version: 0.9.0

看起來一切正常。shellcheck 回報的版本是 0.9.0,正是我指定的版本。

現在,該對我的 run.sh 腳本執行 shellcheck 了。

$ shellcheck -o all run.sh
In run.sh line 6:
python $MAIN_SCRIPT
       ^----------^ SC2248 (style): Prefer double quoting even when variables don't contain special characters.
       ^----------^ SC2250 (style): Prefer putting braces around variable references even when not strictly required.

Did you mean:
python "${MAIN_SCRIPT}"

For more information:
  https://www.shellcheck.net/wiki/SC2248 -- Prefer double quoting even when v...
  https://www.shellcheck.net/wiki/SC2250 -- Prefer putting braces around vari...

嘿,成功了!

這看起來是小事,卻解決了我過去遇到的一個大問題。我喜歡在所有專案中將 shellcheck 作為 Git pre-commit hook 來執行,但過去我總是得依賴單一的、全系統共用的 shellcheck 版本。如果我發現 shellcheck 有想套用到某個專案的新規則,我的 pre-commit hook 就有可能讓所有專案都開始失敗。

Nix 讓我能為每個專案綁定想執行的 linter 版本。這意味著我可以按專案為單位升級 linter,而不必全域共用單一版本。

使用 direnv 自動載入 Nix 開發 shell

我的 Nix 開發 shell 已經可以運作,但這意味著每次開啟新的終端機視窗時,我都得輸入 nix develop 才能進入 shell。

能自動化這個步驟嗎?答案是可以,只要使用 direnv

direnv 會在你 cd 進入專案目錄時自動載入 Nix shell,當你離開該目錄時,又會自動卸載 shell。

direnv 可以作為一般的 apt 套件取得,但令人困擾的是,在 Debian Bullseye 及更早的版本中,可取得的最新套件是 2.25.0。

由於我使用的是 Nix flakes,我需要 2.29.0 或更新版本的 direnv,因此我改用官方的 direnv 安裝程式:

curl -sfL https://direnv.net/install.sh | sudo bin_path=/usr/local/bin bash && echo 'eval "$(direnv hook bash)"' >> ~/.bashrc && . ~/.bashrc

使用 --version 旗標執行 direnv,可以看到我正在使用最新的可用版本:

$ direnv --version
2.32.3

要在我的專案中啟用 direnv,我需要前往存放 Nix flake 的目錄,並執行以下指令:

echo 'use flake .' > .envrc && direnv allow

現在,每當我 cd 進入專案目錄時,direnv 就會自動載入我的 Nix 環境,而當我離開該目錄時則會自動卸載。

為你未擁有的專案建立開發 shell

如果你和我一樣喜歡開發 shell,你會想在參與的每個專案中都使用它。

當你在別人的儲存庫中工作,而對方完全不想採用 Nix 時,該怎麼辦呢?

處理這種情況最簡單的方法,是為你的 Nix flake 建立一個專屬目錄,並將第三方儲存庫放在該目錄下的資料夾中,像這樣:

.
├── examplerepo/ << The actual git repo
├── flake.lock
└── flake.nix

我在「Use a Nix Flake without Adding it to Git(《在不加入 Git 的情況下使用 Nix Flake》)」一文中更詳細地說明了這一點。

每新增一個依賴套件都會讓初始化變慢

我發現 Nix 開發環境最大的缺點是環境載入時間很慢。cd 進入目錄通常只需幾毫秒,但如果需要載入 Nix 環境,可能要花 5 到 10 秒。

更糟的是,依賴套件越多,載入時間就越慢。Nix 必須為每個依賴套件維護一份獨立的 nixpkgs 實例,因此每新增一個想使用的開發工具,就得在目錄載入時間上付出代價。

很遺憾,我還沒找到解決這個問題的方法。

我還沒有在 CI 中使用 Nix 的好解法

既然我花了這麼多功夫為專案建立獨立且可重現的開發環境,自然會想在 continuous integration(持續整合)(CI)中執行程式碼時重複使用這個環境。可惜的是,我還沒找到將 Nix 整合到 CI 工作流程中的實用方法。

Nix 在 CI 中的問題在於,它必須事先做大量工作來建立自己的環境。在我的本地開發系統上,Nix 首次初始化環境需要 60 到 180 秒,通常會從套件伺服器下載數 GB 的資料。

在本地系統上,緩慢的初始化雖然惱人但還可以忍受,因為它只需執行一次。在 CI 上,這就是更大的問題了,因為這意味著原本只需 10 秒的簡單 CI 步驟,現在會膨脹成 2 分鐘的 Nix 初始化加上 10 秒真正要做的事。

我試過使用 Cachix,這是一個專為 Nix 設計的雲端快取。它或許有點幫助,但我始終找不到方法將每個 CI 步驟的初始載入時間縮短到 90 秒以下。

有幾個專為 Nix 打造的 CI 解決方案(GarnixHerculessmithy),但我還沒試過。我希望能在我已經熟悉的 CircleCI 環境中使用 Nix,而不是得去學一套全新的 CI 系統。

我夢想中的功能:由 Nix 管理語言專屬的依賴套件

有一件事 Nix 看起來應該做得到,但我還沒弄清楚怎麼做,那就是管理語言專屬的依賴套件。

舉例來說,如果我用 Nix 建立一個 Python 3 專案,並在 requirements.txt 檔案中列出 pip 依賴套件,我會很希望 Nix 能說:「嘿,你的 requirements.txt 變了!我來幫你把環境更新成一致的狀態。」對於 Node.js 和我的 package.json 檔案也是如此。但到目前為止,我還沒看到讓 Nix 監控這類檔案的方法。

我有看過 poetry2nix,但因為我在 Python 專案中沒有使用 Poetry,所以還沒試過。不過,如果有讀者知道如何實現我所想像的功能,歡迎在留言中告訴我。

更新(2023-10-28):我發現 pyproject.nix 支援單純的 requirements.txt 檔案,所以我現在正在使用它

更新(2025-01-17):我已經停止使用 pyproject.nix,因為我覺得它太讓人困惑。它似乎只對 Nix 維護者已特別移植到 Nix 的 PyPI 套件有效,在其他套件上則會以令人困惑的方式失敗。

常見陷阱

和每一次的 Nix 冒險一樣,Nix 開發環境也有許許多多的陷阱。我在下方列出目前為止遇到的幾個。

Nix 需要將 flake.nix 加入 Git

Nix flakes 有一個奇怪的特性:如果它們位於受 Git 版控的目錄中,但你尚未將 flake.nix 檔案 git add 到儲存庫,你會看到像這樣的令人困惑的錯誤:

error: getting status of '/nix/store/66snibk6a9y3dbam1ww7fj0bdrh0ylw6-source/flake.nix': No such file or directory

如果發生這種情況,你可以用 git add flake.nix 來修正。你甚至不需要提交變更——只要把 flake 加入暫存就夠了。

Go:連結 libc 失敗

在我的 Go 專案中,若有依賴 CGO,我在 Nix 開發環境中嘗試編譯程式碼時曾遇到這個錯誤:

runtime.gcdata: missing Go type information for global symbol .dynsym: size 72
runtime/cgo(.text): relocation target stderr not defined
runtime/cgo(.text): relocation target fwrite not defined
runtime/cgo(.text): relocation target vfprintf not defined

看起來 Go 無法將我的執行檔與 libc 連結。這和影響 Zig 使用者的這個問題類似。

我曾嘗試將 libcmusl 加入 Nix 環境的套件清單,但毫無效果。

唯一能解決這個連結問題的方法,是用 -tags=netgo,osusergo 來編譯我的 Go 應用程式。我完全不知道為什麼這樣會有效。

請參閱我的 PicoShare flake 與建置腳本,以取得在 Nix 環境中建置多平台 Go 執行檔的完整範例。

Golang:version X does not match go tool version Y

在我的某些系統上,執行建置腳本時開始出現這些錯誤:

compile: version "go1.18.4" does not match go tool version "go1.19.6"

結果發現,我的 GOROOT 環境變數指向了 Nix 環境之外的某個 Go 編譯器版本。

快速的解法是執行這個指令:

unset GOROOT

永久的解法是搜尋系統上所有設定環境變數的檔案,找出設定 GOROOT 的那一個。刪除為 GOROOT 賦值的該行之後,我必須重新啟動系統——僅開啟新的 shell 還不夠。

舊版套件無法運作

我試過某些較舊版本的套件,它們完全無法運作。

例如,如果我為 python39 選擇 nixpkgs 版本 b4e193a23a1c5d8794794e65cabf1f1135d07fd9,不僅會讓 Python 壞掉,連 shellcheck 也會一起壞掉:

• Updated input 'python-nixpkgs':
    'github:NixOS/nixpkgs/e2b8feae8470705c3f331901ae057da3095cea10' (2023-10-03)
  → 'github:NixOS/nixpkgs/b4e193a23a1c5d8794794e65cabf1f1135d07fd9' (2021-02-19)
environment:2863: python: command not found
environment:2864: shellcheck: command not found

我最好的猜測是,這麼舊的 nixpkgs 版本早於 Nix flakes 的相容性,而 Nix flakes 仍是 Nix 一項新穎且尚未正式支援的功能。

我的一些 Nix 開發 flake

以下是我至今建立的幾個 Nix 開發 flake:

  • PicoShare - 一個 Go 網頁應用程式
  • mtlynch.io - 一個基於 Hugo、包含 Node.js 依賴的部落格
  • python3_seed - 一個包含 requirements.txt 依賴的基本 Python 應用程式

參考資料

我花了很大功夫才搞懂如何讓 Nix 開發環境運作,因為找不到太多有文件記載的範例。

最終讓我豁然開朗的是 Attila Gulyas(阿提拉·古雅斯)的詳細指南

原文由 Michael Lynch 發布

本文章由 muse-spark-1.2-contributor 進行翻譯