はじめに
macOS環境をnix-darwinとhome-managerで宣言的に管理するようにしました。
完全に手探りでしたが、なんとか一通り宣言できたので、その過程とtipsを備忘録として書いておきます。
また、序盤にはNixとは何か、という話もつらつら書いています。
必要に応じて読み飛ばしてください。
先人のdotfilesを読ませていただきつつ設定していますが、まだ2か月ほどしか触っていない初心者です。
より良い設定や書き方があればコメントください。
リポジトリ
Nix導入前の環境
もともとはchezmoiでdotfilesを管理していました。
そのため、万が一Nixの導入が頓挫しても、最悪chezmoiである程度の環境に戻せる状態でした。
この安心感があったので、比較的気軽にNixを試せました。
nix、home-manager、nix-darwinとは?
ざっくり言うと、dotfilesで管理していた設定に加えて、手作業で入れていたCLIツール、アプリ、macOS設定などもコードとしてまとめて管理できるツール群です。
dotfilesが主に設定ファイルを管理するものだとすると、Nixはそれに加えて、パッケージのインストールやmacOSの設定まで含めて「このMacをどういう状態にしたいか」を宣言できるもの、という理解をしています。
Nix
- 再現性の高いビルドやパッケージ管理を行うための仕組みです。
- Nix式という言語で、パッケージ、開発環境、設定などを宣言的に記述できます。
- 依存関係を含めてNix storeに保存するため、既存環境を壊しにくいです。
- 同じ入力と同じロックファイルなら、別マシンでも近い状態を再現しやすいです。
home-manager
- ユーザー領域の設定を宣言的に管理するツールです。
- git、shell、neovimなどの設定ファイルをNixで管理できます。
- dotfilesで管理していた設定を、Nixの設定として書けるようにするもの、という理解です。
nix-darwin
- macOS向けにシステム設定をNixで管理する仕組みです。
- 例えばDockのサイズや位置、トラックパッドの挙動など、普段「システム設定」で手動変更していた項目をNixで宣言的に管理できます。
nix-darwinの中でhome-managerを使う構成がよく使われていそうだったので、自分もこの構成でシステム設定とユーザー設定をまとめて管理しています。
Nixの利点
大きい利点は次の3つが挙げられるようです。
再現性が高い
「この設定を適用すれば近い環境になる」がやりやすいです。
新しいMacへの移行や、別端末のセットアップがかなり楽になります。
ただし、再現性を高めるにはflake.lockなどでnixpkgsや依存入力を固定しておくことが重要です。
壊しても戻しやすい
世代管理があるので、設定を戻しやすいです。
大きく設定を変えるときの心理的ハードルが下がります。
chezmoiだとapplyしたものを戻すのが少し大変でしたが、Nixでは世代を戻せるので、かなり思い切って適用できました。
設定の一元管理ができる
dotfilesとパッケージ導入手順が分散せず、Nixファイルに集約できます。
「何を入れたか」「なぜこの設定か」を追跡しやすくなります。
特に再現性については、Nixの大きな売りのようです。
ここについては大事そうなので、少し詳しく書きます。
なぜ再現しやすいのか
結論から書くと、再現性の中心は次の2点だと理解しています。
- 成果物をNix store内の固有のstore pathに保存すること
- ビルド時に使う入力を明示し、暗黙の外部状態への依存を減らすこと
Nix storeの考え方
Nixで作られた成果物は、Nix store内のstore pathに保存されます。
store pathにはhashが含まれており、多くの場合、その成果物を作るための入力情報に基づいて決まります。
入力には例えば次のようなものが含まれます。
- 使用するソースコード
- 依存パッケージ
- ビルドスクリプト
- コンパイラやフラグ
- 対象プラットフォーム
入力が同じなら同じstore pathになりやすく、入力が変われば別のstore pathになります。
この性質があるので、古い成果物を壊さず、新しい成果物を横に並べられます。
なお、fixed-output derivationなど一部では、成果物の内容に基づくstore pathもあります。
そのため、厳密には「常に内容だけで決まる」というより、「多くの場合は入力情報に基づいて決まる」と理解しておくのがよさそうです。
純粋関数的なビルド
Nixでは、derivationを「入力から出力を作る定義」のように扱います。
このとき重要なのは、暗黙の外部状態に依存しないようにすることです。
通常の手動ビルドだと、次のような要素で結果が揺れがちです。
- 端末の環境変数
- たまたま入っているグローバルツール
- ローカル環境のライブラリ
- 実行時点のネットワーク状態
Nixはこれを減らすために、利用する依存を明示し、できるだけ閉じた環境(sandbox環境)でビルドします。
その結果、「同じ入力なら同じ結果になる」状態に近づけています。
binary cacheとの関係
Nixは、同じstore pathの成果物がbinary cacheにあれば、手元で再ビルドせず取得できます。
これにより、一度どこかでビルドされたものを他環境でも使いやすくなります。
ここで効いているのもstore pathです。
入力が同じなら同じstore pathを参照しやすいため、cache hitしやすくなります。
また、同じstore pathは重複して保存されないため、同じ依存を複数の環境で使い回しやすく、結果としてストレージ効率にも寄与します。
導入
Nixインストール
Nixのインストールは以下のコマンドで実行しました。
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
なお、このコマンドはDeterminate Systemsのインストーラです。
現在は標準でDeterminate Nixをインストールします。
宣言した生成物の適用
初回はdarwin-rebuildがまだPATHにないため、次のようにnix run経由で実行できます。
sudo nix run github:nix-darwin/nix-darwin#darwin-rebuild -- switch --flake ".#{{host}}"
nix-darwin導入後は、次のように実行できます。
sudo darwin-rebuild switch --flake ".#{{host}}"
宣言方法・構成
上記コマンドを叩くと、nix-darwinはflake.nixを見にいきます。
flake.nixでは主に次の2つを定義しています。
- inputs:外部依存
- outputs:このflakeが公開する成果物
outputsはinputsを受け取り、このリポジトリが最終的に生成するものを返す関数です。
nix-darwinでは、outputsの中に定義されたdarwinConfigurationsを参照して、指定したhostの構成をマシンに適用します。
また後述しますが、installするpkgsはinputで宣言するnixpkgsのバージョンにより一意に定まります。
このリポジトリでは、nixpkgsにnixos-unstable、nixpkgs-darwinにnixpkgs-unstableを指定しています。
また、Neovimは0.11系を使いたかったため、これが導入されているバージョンnixos-25.05をnixpkgs-neovimとして別で指定しています。
nixos-unstableについて
unstableという名前が付いていても、flake.lockに具体的なrevが記録されます。
そのため、ビルドのたびに毎回その時点のlatestを取りにいくわけではありません。
更新したい場合は、明示的にlock fileを更新します。
このリポジトリでは、outputsをflake.nix内に直接書かず、outputs/default.nixへ委譲しています。
ここからは、自分のリポジトリでどのような構成をとっているかを順に見ていきます。
home-managerとnix-darwinの設定
1.outputs/default.nix
このファイルで「どのsystemを作るか」を決めています。
今はaarch64-darwinを読み込む構成のみを宣言しています。
今後はNixOSの設定も追加したいと思っています。
その後、darwinConfigurationsを作って返しています。
ここがnix-darwinから見える最終成果物です。
2.outputs/aarch64-darwin/default.nix
このファイルでは、src配下のhost定義をまとめて読み込みます。
つまり、このnix-config内で定義しているホスト管理です。
自分はプライベート用のuserと仕事用のohnumaの2つのhostを定義しています。
どちらも似たようなものなのでuserの方の設定を追っていきます。
3. outputs/aarch64-darwin/src/user.nixを読む
ここでは次を定義します。
- userName
- hostName
- 読み込むmodule群
そして最後にlibx.macosSystemを呼びます。
ここでnix-darwinとhome-managerが合流します。
4.libx/macosSystem.nixを読む
このファイルが接着剤です。
やっていることは次です。
- nix-darwin.lib.darwinSystemを呼ぶ
- modules/darwin配下をシステム設定として読み込む
- home-manager.darwinModules.home-managerを追加する
- home-manager.users."".importsでhome設定を差し込む
これにより、1回のdarwin-rebuild switchでシステム設定とhome設定をまとめて反映できるようになっています。
5.実際の設定ファイルへ降りる
ここまで来たら、あとは設定本体です。
- システム設定:modules/darwin配下
- home設定:home/base配下、home/hosts/darwin配下
ここでは具体例としてpackages.nix、fish.nix、system.nixを見ます。
6-1.home-manager側の具体例①
home/base/dev/packages.nixでは、グローバルに必要なパッケージを宣言します。
前述しましたが、ここでインストールされるバージョンは、参照しているnixpkgsのリビジョンによって決まります。
このリポジトリではflake.lockでnixpkgsのリビジョンが固定されるため、同じlock fileを使う限り、同じバージョンを再現しやすくなります。
一部必要に応じてNode.jsなども宣言していますが、Nixでは言語ごとの開発環境をグローバルに入れず、プロジェクトごとにdevShellを用意して、その中でのみ使う構成が一般的です
6-2.home-manager側の具体例②
home/base/core/fish.nixでは、fish設定を宣言的に管理しています。
このファイルは次の3層で構成されています。
- xdg.configFileで関数ファイルを配置
- programs.fish.shellAliasesでaliasを定義
- programs.fish.interactiveShellInitで初期化処理を定義
例えばaliasはNix内で宣言しています。
一方、関数ファイルはxdg.configFileで配置しています。
この宣言により、必要な関数ファイルがfishのfunctions配下へ配置されます。
また、このリポジトリではhome/darwin/default.nixでxdg.enableをtrueにしています。
これにより、xdg.configFileでconfig配下へ設定を配置できるようになります。
このように、Nix内で設定を宣言しつつ、必要に応じてファイルを指定して配置するdotfiles的な運用もできます。
nixはこの柔軟さが便利です。
6-3.nix-darwin側の具体例
macOSのシステム設定はmodules/darwin/system.nixに書いています。
例えばDock設定は次のように宣言できます。
このように、普段GUIで設定していたmacOSの値をNixファイルで管理できます。
「このMac設定はこれ」という状態をコードとして持てるのが非常に嬉しいです。
宣言しているパッケージ、設定ファイル、Mac自体の設定をnix-configというリポジトリで一元管理できるのが魅力です。
ここまで大まかなNix設定の流れを見てきました。
ここからは、実際に宣言するにあたってのtipsやハマったことを書いていきます。
tips
direnvとdevShellでの開発環境構築について
この構成では、グローバルに何でも入れるのではなく、プロジェクトごとに.envrcとuse flakeで開発環境を有効化する運用にしています。
まずdirenv自体の役割ですが、これは「ディレクトリ移動をトリガーに環境変数を自動で切り替えるツール」です。
shellにhookを入れておくと、cdしたタイミングでそのディレクトリの.envrcを評価してくれます。
.envrcにuse flakeと書いておくと、Nixの開発環境を読み込み、PATHや環境変数などをそのプロジェクト向けに切り替えてくれます。
その結果、そのディレクトリ内でflake.nixに宣言したパッケージなどを使えるようになります。
また、.envrcは任意のshell scriptを実行できるため、初回はdirenv allowで明示的に許可する仕組みになっています。
.envrcを書き換えた場合も、再度direnv allowが必要です。
流れはシンプルです。
- プロジェクトルートにflake.nixを置く
- .envrcにuse flakeを書く
- 初回だけdirenv allowを実行する
- 以後はディレクトリに入るだけで必要なツールが有効化される
例えば、別リポジトリではRust用の開発環境をflake.nixで宣言しています。
flake.nixをgit管理したくない場合
上記に関して1つ注意点があります。
git管理されているプロジェクト配下でflakeを使う場合、Nixは基本的にgitで追跡されているファイルを評価対象にします。
そのため、flake.nixを作っただけでgit addしていない状態だと、use flakeで読めず環境が適用されないことがあります。
個人プロジェクトだったり、チーム全員がNixを使っている状況だったりすれば、flake.nixやflake.lockをそのままGit管理してしまうのが一番素直です。
一方で、チームの中で自分だけがNixを使っている場合などは、プロジェクトのリポジトリにNix用のファイルを入れたくなく、ignoreしておきたいです。
ただし、普通に.envrcへuse flakeを書くだけだと、Gitで追跡されていないflake.nixが評価対象に入らず、うまく読めません。
この場合は、flakeの参照先としてpath:.を明示します。
use flake path:.
こうすると、Git管理されたflakeとしてではなく、現在のディレクトリをパスとして扱わせることができます。
そのため、Gitで追跡していないローカル専用のflake.nixでも読み込めます。
ただ、use flake path:.は、flake.nixだけを読む指定ではなく、現在のディレクトリをpath flakeとして扱う指定です。
flakeは評価時にソースツリーを/nix/storeへコピーしてから評価されます。
そのため、path:.をプロジェクトルートで使うと、そのプロジェクトで使っている node_modulesやtargetなどの巨大ファイルがコピーされて処理が遅くなってしまいがちです。
そのため、自分だけが使うflake.nixをGit管理したくない場合は、プロジェクトルートでuse flake path:.とするより、nix/flake.nixのように小さいディレクトリへ分けてuse flake path:./nixのように読む方がストレスなく使うことができます。
use flake path:./nix
brew installとの統合について
nixでhomebrewの管理を統合することができます。
このリポジトリでもnix-darwin側でHomebrew管理を有効化しています。
cleanup = "uninstall"を使うことで、いままで手でbrew installしてきたものを削除することができて、brew自体もnixにて一元管理することができるようになり、非常に嬉しいです。
また、caskにてinstallしたいデスクトップアプリケーションも指定できて、switch時にinstallされます。
外に見せたくはないが、機密情報ではない変数の管理
会社のメールアドレスなど、クレデンシャルではないものの、公開リポジトリにそのまま置くのは少し気が引ける値があります。
このリポジトリでは、そのような値をvarsというflake inputとして読み込む構成にしています。
flake.nixでは、デフォルトのvarsとしてリポジトリ内のvarsディレクトリを参照しています。
この値を各設定から参照することで、gitのユーザー名やメールアドレスなどをまとめて管理できます。
さらに、switch時にvars inputを上書きできるようにしています。
これにより、公開リポジトリ側にはダミー値や公開してよい値を置き、手元では別のvarsを渡して実際の値に差し替える、という運用ができます。
自分の場合は、nix-config本体とは別にvars用のflakeを用意し、必要に応じてswitch時にoverride-inputで差し替えるようにしています。
ただし、この方法はクレデンシャル管理には使わない方がよいです。
Nixの評価に使った値や生成された設定ファイルは、Nix storeに入る可能性があります。
Nix storeに入った値は、自分が想定した管理場所とは別の場所にも残ることになります。
そのため、トークン、パスワード、秘密鍵のようなクレデンシャルをこの方法で扱うのは避けるべきです。
あくまでこの方法は、公開リポジトリには置きたくないが、漏れても即座に権限を奪われるわけではない値を分離するためのもの、という位置づけで行っています。
クレデンシャルをNixで扱う場合は、sops-nixやagenixなどの専用の仕組みを使う方法もあるようですが、自分はそこまではやらないことにしています。
導入後にハマったポイント
brewでインストールしていたfishが消えてshellが壊れかけた
もともとログインシェルには、Homebrewでインストールしたfishを使っていました。
一方で、nix-darwin側ではHomebrewの設定を次のようにしていました。
cleanup = "uninstall"にしていると、Nix側で管理していないHomebrewパッケージはswitch時にアンインストールされます。
そのため、もともと$SHELLに設定していたHomebrew版fishもアンインストールされ、switchが終わった直後からshellまわりのエラーログが止まらなくなってかなり焦りました。
ただ、nix-darwin側で次のようにユーザーのshellをNix管理のfishにする設定を入れていました。
そのため、ターミナルエミュレータを再起動したら、Nix側のfishが使われるようになり復旧しました。
結果的には問題ありませんでしたが、switch直後はかなりヒヤヒヤしました。
ちゃんと安全にやるなら、最初にNixへ移行するときだけでも、chshでログインシェルをmacOS標準のzshやbashに戻しておいた方が安心だと思います。
chsh -s /bin/zsh
fish、direnv、toggletermでPATH順序が崩れる
普段のターミナルではdirenvが効いているのに、NeovimのtoggletermだけNode.jsのバージョンがズレる問題に遭遇しました。
原因は、toggleterm内で新しくfishを起動したときにconfig.fishのPATH調整が入り、direnvが作ったPATH順序が崩れることでした。
対処として、toggleterm側でdirenv execを使い、対象ディレクトリの.envrcを読み込んだ状態でfishを起動するようにしたら解決できました。
require("toggleterm").setup({
open_mapping = [[<c-t>]],
shell = "direnv exec . fish",
})
おわりに
現時点ではaarch64-darwinの設定しか終わっていないので、次はNixOSの設定も追加したいです。
より良い記述方法やリファクタできる箇所があれば、コメントいただけると嬉しいです。
参考
参考にさせていただいたdotfiles1
参考にさせていただいたdotfiles2
最初に読んだ記事
nixの大枠を理解できた(気がした)記事