Per-Project Development Environments with Nix

Michael Lynch

Nixでプロジェクトごとに独立した開発環境を作る

原文は Michael Lynch により に公開されました。 このブログを購読する

Nixは非常に多機能ですが、習得のハードルが高いツールです。単一のパッケージのインストールから、OS上のあらゆるファイルやアプリケーションの管理までこなせます。

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

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

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

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

なぜDockerで開発環境を管理しないのか

私はDockerが好きで、デプロイや一部のDevOpsタスクでは使っていますが、開発環境の管理にはあまり役立つと感じていません。

私はVS CodeでSSH経由で開発しているのですが、Dockerを使うとそれが面倒になります。回避策があることは知っていますが、どれも魅力的に思えたことはありません。

なぜAnsibleで開発環境を管理しないのか

過去6年間、開発環境の管理にAnsibleを使ってきましたが、それなりにうまく機能していました。

ソフトウェアプロジェクトごとに専用の仮想マシンを用意し、すべての依存関係をセットアップするためのAnsible Playbookを紐付けています。

問題は、ちょっと数分だけ何かを試したいときに、わざわざVMを立ち上げ、Playbookを書いて、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ファイルの冒頭付近の行で、使いたい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は、Nixを基盤とした開発者向けツールを販売する企業Jetpackが作成した、無料のパッケージ検索サービスです。

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

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

人間にわかりやすいバージョン文字列、続いてnixpkgsのバージョン文字列、続いてパッケージ名が表示されたNixHubの検索結果のスクリーンショット

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

正直なところ、パッケージのバージョンを正確に固定するのは非常に面倒です。将来的には、欲しいバージョンに対応するGitコミットハッシュをわざわざ調べるという回りくどい手順を踏まずに、単に2.7.18.7が欲しいと指定できるくらいまでNixのツールが進化してくれることを願っています。ただ、現状ではこれが私の知る限りバージョンを固定する最良の方法です。

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に依存しなければなりませんでした。あるプロジェクトに適用したい新しいルールがshellcheckに追加されると、pre-commitフックがすべてのプロジェクトで失敗し始める可能性があるのです。

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

direnvを使ってNix開発シェルを自動的に読み込む

Nixの開発シェルは動作するようになりましたが、このままでは新しいターミナルウィンドウを開くたびに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

direnv--versionフラグ付きで実行すると、最新バージョンを使っていることが確認できます。

$ direnv --version
2.32.3

プロジェクトでdirenvを有効にするには、Nix flakeがあるディレクトリに移動し、次のコマンドを実行します。

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

これで、プロジェクトディレクトリにcdで入るたびにdirenvが自動的にNix環境を読み込み、ディレクトリから出るとアンロードしてくれます。

自分が所有していないプロジェクト用の開発シェルを作る

私のように開発シェルが気に入れば、携わるすべてのプロジェクトで使いたくなるはずです。

他人のリポジトリで作業していて、相手が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が初めて環境を初期化するのに60〜180秒かかり、通常パッケージサーバーから数ギガバイトのデータをダウンロードします。

ローカルシステムでは初期化は一度だけで済むので、遅い初期化は煩わしいものの許容範囲です。しかし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はflake.nixがGit管理下にあることを要求する

Nix flakeの奇妙な癖の1つとして、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へのリンク失敗

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開発用flakeの例

これまでに私が作ったNix開発用flakeの例をいくつか紹介します。

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

参考文献

Nixの開発環境をどうやって動かすのか、文書化された例があまり見つからず苦労しました。

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

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

コメント