Per-Project Development Environments with Nix

Michael Lynch

用 Nix 打造每個專案專屬的開發環境

原文由 Michael Lynch 發布,訂閱此部落格

Nix 是個功能非常廣泛、但學習曲線陡峭的產品。從安裝單一套件到管理作業系統上的每一個檔案與應用程式,它幾乎無所不能。

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

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

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

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

為什麼不用 Docker 來管理開發環境?

我喜歡 Docker,也會用它來做部署和一些 DevOps 相關的工作,但我發現它對管理開發環境沒什麼幫助。

我平常是透過 SSH 在 VS Code 上開發,而 Docker 會讓這件事變得很麻煩。我知道有變通方法,但從來沒覺得哪個好用。

為什麼不用 Ansible 來管理開發環境?

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

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

問題在於,當我只是想花幾分鐘試點東西時,實在不太想為了這樣就開一整台 VM、寫一個 playbook,然後再等 Ansible 花 10 到 20 分鐘把伺服器建置好。

我正慢慢把所有專案從 Ansible 搬到 Nix,因為 Nix 輕量得多。以前用 Ansible 升級一個依賴套件通常要花我大約 20 分鐘,現在用 Nix 大概兩分鐘就能搞定。

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

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

安裝 Nix

首先,安裝 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 上的工作輕鬆非常多。如果我想找某個套件特定版本的 hash,只要在 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 hash。但就目前而言,這是我所知道鎖定版本的最好方法。

認識 flake.nix 檔案

好,我說過會更詳細地說明上面那個 flake.nix 檔案。

我不會把 Nix flake 的所有細節都講完,因為我自己也沒有很深入的理解。我只會說明你自己動手做開發環境所需的最基本概念。如果想更深入了解 Nix flake,可以參考《Practical Nix Flakes》

inputs 區塊是用來放你想在環境中使用的各種 Nix 來源版本。我這裡用的是針對 GitHub 儲存庫的特殊語法,但你也可以從其他來源儲存庫或網址匯入。

{
  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 開發環境

我的 Nix dev shell 已經可以運作了,但這代表我每次開新終端機視窗,都得輸入 nix develop 才能進入環境。

能自動化嗎?答案是可以,只要用 direnv 就行。

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

direnv 本來就可以當作一般的 apt 套件來安裝,但惱人的是,在 Debian Bullseye 及更早的版本上,能裝到的最新版只有 2.25.0。

因為我在使用 Nix flake,需要 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 環境,離開目錄時則會自動卸載。

為不是你擁有的專案建立 dev shell

如果你跟我一樣愛上 dev shell,就會想在每個經手的專案上都用它。

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

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

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

我在〈Use a Nix Flake without Adding it to Git〉一文中更詳細地說明了這個做法。

每多一個依賴,初始化就更慢

我發現 Nix 開發環境最大的缺點就是載入速度慢。平常 cd 進一個目錄應該是毫秒級的事,但如果需要載入 Nix 環境,可能就要花上 5 到 10 秒。

更糟的是,依賴套件越多,載入就越慢。Nix 必須為每個依賴維護各自獨立的 nixpkgs 實例,所以每多加一個開發工具,就得在目錄載入時間上付出代價。

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

我還沒找到在 CI 中使用 Nix 的好方法

既然我已經花了這麼多功夫為專案打造出獨立、可重現的開發環境,自然會想在持續整合(CI)中重用這個環境。可惜的是,我還沒找到把 Nix 整合進 CI 流程的實用方法。

Nix 在 CI 中的問題在於,它一開始就得做大量工作來建立自己的環境。在我的本地開發系統上,Nix 首次初始化環境要花 60 到 180 秒,通常還得從套件伺服器下載好幾 GB 的資料。

這種緩慢的初始化在本地系統上雖然煩人,但因為只需要做一次,還算可以忍受。但在 CI 上就成了更大的問題,因為原本只要 10 秒就能跑完的簡單步驟,現在會膨脹成 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 flake 有一個奇怪的特性:如果它位於受 git 版控的目錄中,但你還沒把 flake.nix 檔案 git add 到儲存庫,就會看到像這樣讓人困惑的錯誤:

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

如果發生這種情況,只要執行 git add flake.nix 就能解決。你甚至不需要 commit,只要把 flake 加進去就夠了。

Go:無法連結到 libc

在我那些依賴 CGO 的 Go 專案中,我曾在 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 無法將我的 binary 連結到 libc。這跟這個影響 Zig 使用者的問題很類似。

我試著在 Nix 環境的套件清單中加入 libcmusl,但都沒有效果。

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

關於在 Nix 環境中建置多平台 Go binary 的完整範例,可以參考我的 PicoShare flake 和建置腳本

Golang:version X 與 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 flake 的相容支援,而 Nix flake 本身還是一個新推出、尚未正式支援的功能。

我的一些 Nix dev flake

以下是到目前為止我做過的幾個 Nix dev flake:

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

參考資料

我花了不少時間才搞懂怎麼讓 Nix 開發環境動起來,因為能找到的文件範例實在不多。

最後讓我真正搞懂 Nix 環境的關鍵,是 Attila Gulyas 的詳細指南

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

留言