Per-Project Development Environments with Nix

Michael Lynch

Nixでプロジェクトごとに開発環境を構築する

Nixは非常に守備範囲が広く、習得曲線も急なツールです。単一のパッケージのインストールから、OS上のあらゆるファイルやアプリケーションの管理までこなせます。

そんなNixでも、完全な初心者であってもすぐに役立てられる使い方があります。それが開発環境の管理です。

Nixを使えば、同じマシン上で複数のプロジェクトを、それぞれ独立した依存関係のビューを持たせて共存させられます。たとえば、Python 2.7とNode.js 4.xで動くレガシーなプロジェクトと、Python 3.11とNode.js 20で動くモダンなプロジェクトを並行して保持しても、互いに干渉することはありません。

Nixの経験がまったくなくても、20分ほどの作業でNixで管理された開発環境を使い始められます。

:筆者はまだNix初心者ですので、ここで紹介する方法が最適解かどうかはわかりません。もしNixに詳しい方で改善案があれば、ぜひ教えてください。いただいた内容をもとに記事を更新します。

Dockerで開発環境を管理しない理由

Dockerは好きですし、デプロイや一部のDevOps作業では使っています。ただ、開発環境の管理には有用だと感じていません。

筆者はVS CodeでSSH越しに開発を行っていますが、Dockerがあるとそれが面倒になります。回避策があることは知っていますが、どれも魅力的に感じたことはありません。

Ansibleで開発環境を管理しない理由

この6年間、開発環境の管理にはAnsibleを使ってきました。それなりにうまく回っていました。

ソフトウェアプロジェクトごとに専用の仮想マシンと、そのVMをすべての依存関係で構成するためのAnsibleプレイブックを作成するという運用です。

問題は、ちょっとしたことを数分試したいだけのときに、わざわざVMを立ち上げ、プレイブックを書いて、Ansibleのプロビジョニングが終わるまで10〜20分待たなければならないことです。そうなると、なかなか試す気になれませんでした。

現在は、すべてのプロジェクトを少しずつAnsibleからNixへ移行しています。Nixの方がはるかに軽量だからです。Ansibleでは依存関係を1つアップグレードするのに通常20分ほどかかっていましたが、Nixなら同じ作業を2分程度で済ませられます。

シンプルな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の開発環境の動きを示すため、2020年に正式にサポートが終了したレガシーなPythonであるPython 2.7で動くシンプルなアプリケーションを作成します。

まずはプロジェクト用の新しいディレクトリを作成します。

mkdir example && cd example

次に、Nix開発環境を定義するファイルであるNix flakeをダウンロードまたはコピーします。

{
  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開発環境を立ち上げます。なお、初回実行時は初期化に数分かかりますが、2回目以降は数秒で完了します。

# 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の環境が利用できるようになりました。

なお、Python 2.7はこの特定のNix環境以外には一切インストールされていません。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ファイルの最初の数行の1つで、使いたいPythonパッケージの正確なバージョンを宣言しています。

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

# 2.7.18.7 releaseという行は筆者自身のためのコメントにすぎません。Nixはこれを無視します。実際に重要なのはpython-nixpkgsの行です。

NixOS/nixpkgsGitHubリポジトリで、517501bcf14ae6ec47efd6a17dda0ca8e6d866f9は、python2パッケージがPython 2.7.18.7に対応していた時点のリポジトリのバージョンです。

この長いバージョン文字列をどうやって調べたのかというと、Nixhubを使いました。

検索ダイアログが表示されたNixHubのランディングページのスクリーンショット

NixhubはJetpackが作成した無料のパッケージ検索サービスです。JetpackはNixの上に構築された開発者向けツールを提供している企業です。

Nixhubが公開されたのはわずか3か月前ですが、おかげでNixでの作業が格段に楽になりました。特定のバージョンのパッケージのハッシュを探したいときは、Nixhubで検索してコミットIDを見つければよいのです。

たとえばPython 2.7.18.7のバージョン文字列を探すために、pythonでNixhubを検索し、結果のリストをスクロールして入手可能な最新のPython 2.7.xバージョンを探しました。

人間が読みやすいバージョン文字列が最初に表示され、次にnixpkgsのバージョン文字列、最後にパッケージ名が表示されるNixHubの検索結果のスクリーンショット

NixHubを使えば、人間が読みやすいバージョン文字列をnixpkgsの参照とパッケージ名に変換できます。

正確なパッケージバージョンを固定する作業は、正直なところ非常に面倒です。バージョン2.7.18.7が欲しいと直接指定するだけで済むように、Nixのツールが進化してくれることを願っています。今は、欲しいバージョンに対応するgitコミットハッシュを調べるという回りくどい手順を踏む必要があります。しかし現時点では、これがバージョンを固定するための最善の方法です。

flake.nixファイルの読み解き

先ほど紹介したflake.nixファイルについて、もう少し詳しく説明します。

筆者はNix flakeについて深く理解しているわけではないので、すべてを解説するつもりはありません。自分で開発環境を作るために最低限知っておくべきことだけを説明します。Nix flakeについてより深く知りたい方は、「Practical 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シェルの開発環境を定義します。packagesには環境で使いたいすべてのパッケージを列挙します。

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

ほとんどのパッケージでは、パッケージ名にバージョンは含まれません。htopvimのようなパッケージでは、パッケージ名は常に同じです。しかしPythonのような一部のパッケージでは、同じNixpkgsのバージョン内に複数のバージョンが存在するため、Python 3と混同しないようにpythonではなくpython2と明示的に指定する必要があります。

最後に重要なのがshellHookセクションです。

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

Nixはシェルに移る直前にshellHook内のコマンドを実行します。ここには任意のシェルコマンドを記述できます。

筆者は、Nix flakeが正しく動作しているか一目でわかるように、依存関係のバージョンを表示するコマンドを入れるようにしています。

Python 3へのアップグレード

では、1行だけのPythonアプリをPython 2.7からモダンなPython 3へ移植するという大仕事に取りかかるとしましょう。更新が必要なのは次の2か所だけです。

{
    # 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シェルを終了し、次のコマンドで新しい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スクリプト用の優れたリンターで、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シェルを終了し、次のコマンドで新しいシェルを立ち上げます。

$ 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フックとして実行したいと思いつつ、システム全体で単一のバージョンに依存せざるを得ませんでした。あるプロジェクトで新しいルールが追加されたshellcheckを使いたいと思ったとき、pre-commitフックがすべてのプロジェクトで失敗し始める可能性があったのです。

Nixを使えば、プロジェクトごとに使いたいリンターのバージョンを紐付けられます。つまり、グローバルに単一のバージョンを共有するのではなく、プロジェクト単位で新しいリンターへアップグレードできるのです。

direnvでNix開発シェルを自動読み込みする

Nixのdev shellは動くようになりましたが、新しいターミナルウィンドウを開くたびにnix developと入力してシェルに入らなければなりません。

これは自動化できないのでしょうか。実はdirenvを使えば可能です。

direnvは、プロジェクトのディレクトリにcdしたときに自動でNixシェルを読み込み、ディレクトリから出たときに自動でアンロードしてくれます。

direnvは通常のaptパッケージとしても入手できますが、残念ながらDebian Bullseye以前では入手可能な最新パッケージは2.25.0です。

Nix flakeを使っているためdirenv2.29.0以降が必要なので、代わりに公式の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インスタンスを維持する必要があるため、使いたい開発ツールが1つ増えるたびに、ディレクトリの読み込み時間という代償を払わなければなりません。

残念ながら、この問題の回避策はまだ見つかっていません。

CIでのNix活用には良い解決策がまだありません

プロジェクトのために独立して再現可能な開発環境を作るためにこれだけの作業をしたのですから、当然その環境を継続的インテグレーション(CI)でも再利用したいところです。残念ながら、NixをCIワークフローに統合する実用的な方法はまだ見つかっていません。

CIにおけるNixの問題は、Nixが独自の環境を作るために事前に多くの作業を必要とすることです。ローカルの開発環境では、Nixは最初の1回だけ環境の初期化に60〜180秒かかり、通常はパッケージサーバーから数ギガバイトのデータをダウンロードします。

この初期化の遅さは、1回だけで済むローカル環境では煩わしいものの許容範囲です。しかしCIでは、10秒で終わっていたシンプルなCIステップが、Nixの初期化に2分かかり、さらに本来やりたい処理に10秒かかるというように膨れ上がってしまうため、より大きな問題になります。

Nix専用のクラウドキャッシュであるCachixも試してみました。多少は改善したかもしれませんが、CIステップごとの初期読み込み時間を90秒以下に抑える方法は見つかりませんでした。

Nixを前提に構築されたCIソリューションもいくつか存在します(GarnixHerculessmithy)。ただ、まだ試していません。まったく新しいCIシステムを学ぶのではなく、使い慣れたCircleCI環境でNixを使いたいと考えています。

理想の機能:言語固有の依存関係もNixで管理したい

Nixでできそうでいて、まだ実現方法がわからないことが1つあります。それが言語固有の依存関係の管理です。

たとえば、Nixでrequirements.txtファイルにpipの依存関係リストを持つPython 3プロジェクトを作成した場合、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の奇妙な癖の1つとして、gitで管理されているディレクトリにflakeがあるのに、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へのリンク失敗

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がバイナリをlibcに対してリンクできていないようです。Zigユーザーに影響するこのissueと似ています。

Nix環境のパッケージリストにlibcmuslを追加してみましたが、効果はありませんでした。

リンクエラーを解消する唯一の方法は、Goアプリを-tags=netgo,osusergo付きでコンパイルすることでした。なぜそれで直るのかはまったくわかりません。

Nix環境でマルチプラットフォームのGoバイナリをビルドする完全な例については、PicoShareのflakeとビルドスクリプトをご覧ください。

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に値を割り当てている行を削除した後、システムを再起動する必要がありました。新しいシェルを立ち上げ直すだけでは不十分でした。

古いバージョンのパッケージが動かないことがある

特定の古いバージョンのパッケージを試したところ、まったく動かないことがありました。

たとえば、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の新しくまだ正式にはサポートされていない機能であるNix flakeとの互換性が生まれる前のものなのでしょう。

筆者のNix dev flakeの例

これまでに作成したNix dev flakeの例をいくつか紹介します。

  • PicoShare - Go製ウェブアプリ
  • mtlynch.io - Node.js依存関係を持つHugoベースのブログ
  • python3_seed - requirements.txtの依存関係を持つ基本的なPythonアプリ

参考資料

Nixの開発環境をどうやって動かすのか、なかなか分からず苦労しました。文書化された事例がほとんど見つからなかったためです。

最終的にNix環境の仕組みを理解するきっかけになったのは、Attila Gulyas氏による詳細なガイドでした。

原文は Michael Lynch により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。