All in Homebrew をやめ、nix-darwin で Mac を管理する

2025年5月10日土曜日 / , , , ,
簡体中文日本語
要約:
数年にわたりすべてのソフトウェアを Homebrew で管理してきましたが、バージョンと依存関係の問題が増えてきたため、nix-darwin を導入しました。コマンドラインツールは Nix に、GUI アプリは引き続き Homebrew に、言語環境は引き続き mise に管理させています。

2021 年から、私の Mac 上のほとんどすべてのソフトウェアは Homebrew でインストールしていました。コマンドラインツールは formula、GUI アプリは cask で入れ、最後に Brewfile でそれらをすべて記録していました。この方法は確かに便利で、マシンを買い替えるときも brew bundleを 1 回実行するだけで元通りにでき、ソフトウェアの削除もかなりきれいにできます。

しかし数年使ううちに、問題もどんどん増えていきました。

Homebrew の問題

まずはバージョンです。Homebrew の formula は基本的に最新版しか提供しておらず、少数の openjdk@17、python@3.12 のようなバージョン番号付きの formula を除いて、特定の古いバージョンをインストールするのはとても面倒です。さらに brew upgrade を実行するだけで全部が一緒にアップグレードされてしまい、たまにツールをアップグレードした後に問題が起きても、元に戻すのは簡単ではありません。

次は依存関係です。Homebrew で formula をインストールすると、その依存関係も一緒にインストールされ、それらが自分で入れたものと混ざってしまいます。最も頭を悩ませたのは Rust です。私の Rust は mise でインストールしていますが、ある formula が Rust に依存しているだけで、Homebrew は自分用の Rust をさらにインストールしてしまいます。その結果、/opt/homebrew/bin の中にももう 1 セットの cargo と rustc が入ってしまいます。2 セットの Rust のどちらが先に見つかるかは PATH の順序次第で、ときどき cargo build が思っていたのとは違うバージョンを使っていることがあります。

そのため、現在の cargo が Homebrew 由来かどうかを確認する関数までわざわざ書きました。

Shell
# check if using correct cargo
function check_cargo() {
  cargo_path=$(which cargo 2>/dev/null)
  if [[ -z "$cargo_path" ]]; then
    echo "Cargo is not installed or not in PATH."
    return 1
  fi
  if [[ "$cargo_path" == "/opt/homebrew/bin/cargo" ]]; then
    echo "$cargo_path cargo path error"
  fi
}

Python にも似たような状況があります。私の Brewfile にはいくつか Python パッケージがあり、たとえば numpy、cffi、cryptography は、いずれも他の formula によって依存関係として持ち込まれたもので、自分では一度もインストールしたことがありません。ついでに python@3.12 まで持ち込まれ、mise で入れた Python とも混ざってしまっています。

最後に Brewfile 自体です。ずっと使ってはいましたが、実質は後から記録するもので、何かをインストールした後に brew bundle dump して現在の状態をエクスポートしていました。しかも dump されたものには、自分で積極的に入れたわけではなく、依存として入っただけの formula が大量に混ざっていて、Brewfile はどんどん長くなり、最終的には formula が 200 個近くになりました。そのうち自分で入れたのは半分くらいだったかもしれません。

なぜ Nix なのか

以前しばらく NixOS を使っていて、その宣言的にシステムを管理する方式がずっと気に入っていました。すべてを設定ファイルに書き、欲しいものは書き、不要になったら消して、そして rebuild するだけで、システムは設定に書かれたとおりの姿になります。私が当時 Brewfile を維持し続けたのは、実はこの宣言的な感覚のためでしたが、Brewfile はそこまで徹底してはいませんでした。

Nix のパッケージはすべて /nix/store にインストールされ、各パッケージは独自のパスを持ち、依存関係もそれぞれ隔離されています。したがって、あるツールがビルド時に Rust に依存していても、その Rust はそのツール自身の依存関係の中にだけ存在し、私の PATH には現れませんし、mise で入れた Rust とぶつかることもありません。しかも nixpkgs にはあらかじめコンパイル済みのバイナリキャッシュがあるので、ほとんどのパッケージは Homebrew と同じくらい速くインストールできます。

それでは Mac 上で Nix を使ってシステムを管理するには、nix-darwin を使う必要があります。これは macOS における NixOS の設定モジュールに相当し、ソフトウェアのインストールだけでなく、システム設定、launchd サービス、環境変数の管理もでき、さらに Homebrew モジュールまであります。Nix の設定から Homebrew でインストールするものを宣言できるのです。

どう役割分担するか

ただし、Nix が何にでも向いているわけではありません。GUI アプリは nixpkgs にすべて揃っているわけではなく、仮にあっても公式の dmg のほうが使いやすいものが少なくありません。Homebrew の cask はこの点ですでに非常によくできています。また、プログラミング言語のランタイムについては、プロジェクトごとに異なるバージョンを使いたいのですが、それを Nix に任せるのは重すぎます。

そこで最終的に、私は次のように分担しました。

担当するもの 任せる先
システム設定、コマンドラインツール、launchd サービス nix-darwin
GUI アプリ(cask) Homebrew(nix-darwin が宣言)
プログラミング言語とそのツールチェーン mise
Mermaid diagram

インストール

まず Nix をインストールし、flakes を有効にします。その後、nix-darwin の説明に従って、/etc/nix-darwin の下にテンプレートから作成します。ファイル名は flake.nix。

Shell
sudo mkdir -p /etc/nix-darwin
sudo chown $(id -nu):$(id -ng) /etc/nix-darwin
cd /etc/nix-darwin

nix flake init -t nix-darwin/master
sed -i '' "s/simple/$(scutil --get LocalHostName)/" flake.nix

初回インストール時には、システムにまだ darwin-rebuild というコマンドがないので、nix run で一度実行する必要があります。

Shell
nix run nix-darwin/master#darwin-rebuild -- switch

以降は設定を変更するたびに、次のコマンドを実行するだけで反映されます。

Shell
darwin-rebuild switch

設定

テンプレートから生成された flake.nix では、すべての設定が configuration という関数の中に書かれているので、私もそのまま中に追記していきました。以下が現在の設定のおおよその姿で、一部のソフトウェアは省略しています。

NIX
{
  description = "Brian's nix-darwin system";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    nix-darwin.url = "github:nix-darwin/nix-darwin/master";
    nix-darwin.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = inputs@{ self, nix-darwin, nixpkgs }:
  let
    configuration = { pkgs, ... }: {
      nixpkgs.hostPlatform = "aarch64-darwin";
      nixpkgs.config.allowUnfree = true;
      nix.settings.experimental-features = "nix-command flakes";

      environment.systemPackages = [
        # 常用命令行工具
        pkgs.bat
        pkgs.eza
        pkgs.fd
        pkgs.fzf
        pkgs.ripgrep
        pkgs.jq
        pkgs.btop
        pkgs.yazi
        pkgs.zellij
        pkgs.zoxide
        pkgs.starship
        pkgs.gh
        pkgs.helix
        pkgs.neovim
        # 开发工具
        pkgs.cmake
        pkgs.ninja
        pkgs.just
        pkgs.lazygit
        pkgs.hyperfine
        pkgs.tokei
        # 网络工具
        pkgs.mtr
        pkgs.nmap
        pkgs.iperf3
        pkgs.doggo
        pkgs.nexttrace
        # 语言环境交给 mise
        pkgs.mise
      ];

      homebrew = {
        enable = true;
        taps = [ "xxxbrian/tap" ];
        brews = [
          "openssl@3"
          "sqlite"
          "readline"
          "libyaml"
          "pkgconf"
          "icu4c"
          "zlib"
        ];
        casks = [
          "1password"
          "ghostty"
          "google-chrome"
          "orbstack"
          "raycast"
          "surge"
          "telegram"
          "visual-studio-code"
          "zed"
        ];
        onActivation.cleanup = "zap";
      };

      security.pam.services.sudo_local.touchIdAuth = true;

      system.defaults = {
        NSGlobalDomain = {
          ApplePressAndHoldEnabled = false;
          KeyRepeat = 2;
          InitialKeyRepeat = 15;
        };
        dock = {
          autohide = false;
          show-recents = false;
          tilesize = 58;
        };
      };

      system.configurationRevision = self.rev or self.dirtyRev or null;
      system.stateVersion = 6;
    };
  in
  {
    darwinConfigurations."brian-mbp-m1max" = nix-darwin.lib.darwinSystem {
      modules = [ configuration ];
    };
  };
}

コマンドラインツール

environment.systemPackages に並ぶのがインストールするコマンドラインツールで、名前は基本的に Homebrew と同じです。違う場合は search.nixos.org で検索してみてください。インストール後は、それらがすべて /run/current-system/sw/bin に現れます。

nixpkgs のパッケージが自分が欲しいものと少し違うときは、overlay で変更することもできます。たとえば nexttrace は私の環境では libpcap を追加でリンクする必要があるので、次のようにビルド引数を上書きできます。

NIX
nixpkgs.overlays = [
  (final: prev: {
    nexttrace = prev.nexttrace.overrideAttrs (old: {
      buildInputs = (old.buildInputs or [ ]) ++ [ prev.libpcap ];
    });
  })
];

Homebrew

nix-darwin は Homebrew をインストールしてはくれないので、Homebrew 自体は従来どおりの方法でインストールしておく必要があります。その後、nix-darwin は homebrew の設定部分から Brewfile を生成し、毎回の switch 時に brew bundle を呼び出して実行します。

つまり、Brewfile は今後、私がマシンから dump したものではなく、設定から生成されるものになります。こうすることで中身は自分で明示的に書いた cask だけになり、依存関係の山が混ざり込むことはなくなります。

onActivation.cleanup = "zap" が私にとって最も重要な項目です。これを設定すると、毎回の switch で、設定にない formula と cask はすべてアンインストールされます。しかも --zap 付きのアンインストールなので、アプリが ~/Library に残したものも一緒に削除されます。ですからソフトウェアを削除したいときは、設定からその行を消して switch するだけです。

ただし、そのため最初の switch の前には必ず cask をすべて列挙しておく必要があります。そうしないと、書き忘れたアプリはそのデータごと削除されてしまいます。私はまず brew list --cask ですべての cask を一覧表示し、一つひとつ確認してからようやく実行しました。

brews にはライブラリをいくつかだけ残しました。たとえば openssl@3、readline、sqlite、zlib です。ローカルでコンパイルが必要なものがあるからです。たとえば mise で特定の言語バージョンをコンパイルしたり、C 拡張付きのパッケージをビルドしたりするとき、これらのビルドスクリプトは Mac 上ではデフォルトで Homebrew のパスにあるこれらのライブラリを探しに行きます。なので、Homebrew 側に残しておくほうが逆に一番楽です。

システム設定

system.defaults では、本来ならシステム設定でクリックしたり、defaults write で変更したりする必要がある多くの項目を設定できます。たとえばキーリピートの速度、Dock のサイズや動作などです。自分が慣れている項目はすべて書き込んだので、別のマシンに乗り換えてもこの設定も一緒に移行できます。

security.pam.services.sudo_local.touchIdAuth は、sudo で Touch ID 認証を使えるようにします。有効にすると、ターミナルで sudo と入力してもパスワードを打つ必要がなくなり、指紋を一度触れるだけで済みます。

launchd サービス

以前 brew services で動かしていたバックグラウンドサービス、たとえば aria2 も、今では nix-darwin の中で直接 launchd のユーザーサービスとして宣言できます。

NIX
environment.userLaunchAgents.aria2 = {
  enable = true;
  target = "org.nix-darwin.aria2.plist";
  text = ''
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0">
    <dict>
      <key>Label</key>
      <string>org.nix-darwin.aria2</string>
      <key>ProgramArguments</key>
      <array>
        <string>${pkgs.aria2}/bin/aria2c</string>
      </array>
      <key>RunAtLoad</key>
      <true/>
      <key>KeepAlive</key>
      <true/>
    </dict>
    </plist>
  '';
};

${pkgs.aria2} は、aria2 の /nix/store 内の実際のパスに置き換えられるので、このサービスは常に設定内のそのバージョンを指します。

mise

各種言語のバージョンは、去年すでにすべて mise に任せており、今回も変わりません。唯一の違いは、mise 自体を以前は Homebrew でインストールしていたのが、今は Nix でインストールするようになったことです。それは上の systemPackages にある行、すなわち pkgs.mise。~/.config/mise/config.toml は元のままで、Nix は触りません。

こうして今は三者の境界がはっきりしています。Nix がシステムとコマンドラインツールを、Homebrew が GUI アプリを、mise が言語を管理します。コマンドラインツールがすべて Nix 製に変わったので、Homebrew が依存関係のせいでこっそり Rust を入れることもなくなり、which cargo は常に mise が管理するものを指します。

移行

移行は少しずつ進めました。

  1. まず brew leaves で自分で明示的にインストールした formula をすべて一覧表示し、nixpkgs で対応するパッケージを探して environment.systemPackages に書きます。
  2. 次に brew list --cask ですべての cask を一覧表示し、そのまま homebrew.casks に書きます。
  3. 確認が済んだら、onActivation.cleanup = "zap" を有効にし、darwin-rebuild switch を実行して、設定にない formula をすべて削除させます。

最後のステップを実行し終えると、brew leaves には先ほどのいくつかのライブラリだけが残り、コマンドラインツールはすべて /run/current-system/sw/bin 配下の Nix 版に置き換わりました。

現在

現在、コマンドラインツールを入れたいときは、systemPackages に 1 行追加し、アプリを入れたいときは、casks に 1 行追加してから darwin-rebuild switch を実行します。削除するときも同じで、その行を消して switch するだけです。このマシンに何が入っていて、システムがどう設定されているかは、すべて /etc/nix-darwin/flake.nix という 1 つのファイルに書かれています。

しかも毎回の switch で新しいシステム世代が生成され、設定を壊してしまっても darwin-rebuild switch --rollback で前の世代に戻せます。これは Homebrew を使っていたときにはまったく考えられなかったことです。

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...