• 作成:

DevContainer上でNix Flake環境を構築する

背景

チーム開発でDevContainerを使っているプロジェクトがあり、 CIや開発環境を整える作業を任されました。

私はVSCodeを一切使わないので当然DevContainerも使わないのですが。

自分はNix以外使う気があまりしないので、 Nix Flakesで管理したくなりました。既にCI環境とかはNixを使って管理していましたし。

DockerfileやDevContainer Featuresだけで開発環境を管理すると、再現性が低くなって私がメンバーの問題を再現して解決するのが面倒だったり、 DockerのキャッシュはNixよりレイヤー依存で切れがちなので、環境構築に時間がかかる問題があります。

なのでNix環境を簡単にバラまければ良いんじゃないかと思いました。自分がNixの環境を管理すれば他の人はnix flake checkとか実行するだけですからね。流石にコマンドを実行するだけなら難しくもなんともないはず。

なのでNixをDevContainerの上で動かせば良いじゃんと思ったので、許可を取ってやりました。

これでNix Flakes環境を変更するだけでDevContainer上の環境も自動で変わるはずです。

DockerfileでNixをインストールしてもボリュームマウントで消える

問題

最初はDockerfileでNixをインストールしようとしました。しかしNixの/nixディレクトリをボリュームマウントで永続化すると、コンテナ起動時に空のボリュームがマウントされて、 Dockerfileでインストールした内容が見えなくなってしまいます。

ボリュームマウントしないという選択肢もありますが、そうするとコンテナを再作成するたびにNixのビルドキャッシュが消えてしまいます。キャッシュサーバがあるとは言え、キャッシュがないとダウンロードやビルドに時間がかかるので、ボリュームマウントは必須と考えました。

解決

コンテナ作成後に実行されるpostCreateCommandでNixをインストールすることにしました。ボリュームマウント後に実行されるので、インストールした内容がちゃんと永続化されます。

実装

devcontainer.json

{
  "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.schema.json",
  "name": "My Project",
  "dockerComposeFile": "docker-compose.yml",
  "service": "dev",
  "workspaceFolder": "/workspaces/my-project",
  "mounts": [
    "source=nix-store-${devcontainerId},target=/nix,type=volume"
  ],
  "postCreateCommand": "bash .devcontainer/postCreateCommand.sh",
  "customizations": {
    "vscode": {
      "settings": {
        "direnv.restart.automatic": true,
        "nix.enableLanguageServer": true,
        "nix.serverPath": "nil",
        "nix.serverSettings": {
          "nil": {
            "formatting": {
              "command": ["nixfmt"]
            }
          }
        }
      },
      "extensions": [
        "jnoortheen.nix-ide",
        "mkhl.direnv"
      ]
    }
  },
  "remoteUser": "vscode"
}

/nixをボリュームマウントしてNixストアを永続化しています。 ${devcontainerId}を使うことでプロジェクトごとに独立したボリュームになります。

VS Code拡張機能としてjnoortheen.nix-idemkhl.direnvを追加しています。 direnv拡張機能はdirenv.restart.automaticを有効にすることで、 .envrcの変更時に自動で環境を再読み込みしてくれます。

もしかしたらdirenvは拡張機能より、 devcontainers-community/features-direnv: 👩‍💻 Installs direnv ベースのほうがスムーズなのかもしれません。

docker-compose.yml

services:
  dev:
    image: mcr.microsoft.com/devcontainers/base:ubuntu-24.04
    user: vscode
    command: sleep infinity
    env_file:
      - path: ../.env.local
        required: false
    volumes:
      - ..:/workspaces/my-project:cached

ベースイメージはMicrosoftの公式DevContainerイメージを使っています。 Dockerfileを書かずにシンプルに済ませています。

どうせスクリプトでnixなどをインストールするならDockerfileで細かなカスタムをする必要はありません。 nixのイメージと言えば、 nixos/nix - Docker Image もありますが、 DevContainer向けの設定がされてそうな方を選びました。

gitignoreしている.env.localから環境変数を読み込むようにしています。これはCachixの認証トークンを渡すために使います。 required: falseにすることでファイルがなくても起動できるようにしています。ホストの方にnixがインストールされていればcachixもインストールしてもらうだけですが、今回はホストの方にはインストールしなくていいという前提で進めたのでファイルにトークンだけ書き込んでもらう形にしました。

postCreateCommand.sh

#!/usr/bin/env bash
set -euo pipefail

# cachixのトークンは秘密なのでデバッグログを明示的に有効にしない
set +x

# Nixのインストール
# `/nix`がボリュームマウントされているため、
# コンテナ作成後にインストールが必要
NIX_VERSION=2.31.2
NIX_INSTALL_SHA256=078e2ffeddf6a9c1f22adf41458ccc46a58bb26911a9e01579645314f9982994

if ! command -v nix >/dev/null 2>&1; then
  echo "Nix not found. Installing Nix ${NIX_VERSION}..."
  curl -L "https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" -o /tmp/nix-install.sh
  echo "${NIX_INSTALL_SHA256}  /tmp/nix-install.sh" | sha256sum -c -
  sh /tmp/nix-install.sh --no-daemon
  rm /tmp/nix-install.sh
  echo "Nix installation complete."
fi

# Nixのユーザグローバル設定
if [ ! -f ~/.config/nix/nix.conf ] || ! grep -q "experimental-features" ~/.config/nix/nix.conf; then
  echo "Configuring Nix..."
  mkdir -p ~/.config/nix
  cat >~/.config/nix/nix.conf <<'NIXCONF'
experimental-features = nix-command flakes
accept-flake-config = true
substituters = https://cache.nixos.org https://nix-community.cachix.org https://your-cache.cachix.org
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs= your-cache.cachix.org-1:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=
NIXCONF
  echo "Nix configuration complete."
fi

# Nixプロファイルを読み込む
if [ -e ~/.nix-profile/etc/profile.d/nix.sh ]; then
  # shellcheck source=/dev/null
  . ~/.nix-profile/etc/profile.d/nix.sh
fi

# Nixプロファイルの読み込みを.bashrcに追加
# VS Codeのターミナルは非ログインシェルなので.profileは読み込まれない
# shellcheck disable=SC2016
if ! grep -q "nix-profile/etc/profile.d/nix.sh" ~/.bashrc; then
  echo "Configuring Nix profile for bash..."
  echo '[ -e "$HOME/.nix-profile/etc/profile.d/nix.sh" ] && . "$HOME/.nix-profile/etc/profile.d/nix.sh"' >>~/.bashrc
fi

# direnvをグローバルにインストール
# direnvはdirenv自身を読み込む必要があるのでdevShellsに入れるだけでは不十分
echo "Installing global packages via Nix profiles..."
if ! command -v direnv >/dev/null 2>&1; then
  nix profile add .#direnv .#nix-direnv
fi

# nilはVS CodeのNix拡張機能がうまくdirenvの環境を認識できないのでグローバルに入れる
if ! command -v nil >/dev/null 2>&1; then
  nix profile add .#nil
fi

echo "Global package installation complete."

# nix-direnvの設定
if [ ! -f ~/.config/direnv/direnvrc ] || ! grep -q "nix-direnv" ~/.config/direnv/direnvrc; then
  echo "Configuring nix-direnv..."
  mkdir -p ~/.config/direnv
  cat >>~/.config/direnv/direnvrc <<'DIRENVRC'
# nix-direnvを使用してキャッシュを有効化
source $HOME/.nix-profile/share/nix-direnv/direnvrc
DIRENVRC
  echo "nix-direnv configuration complete."
fi

# direnvフックをシェルごとに設定
# shellcheck disable=SC2016
if ! grep -q "direnv hook bash" ~/.bashrc; then
  echo "Configuring direnv hook for bash..."
  echo 'eval "$(direnv hook bash)"' >>~/.bashrc
fi

# Cachix認証設定
if [ -n "${CACHIX_AUTH_TOKEN:-}" ]; then
  echo "Setting up Cachix authentication..."
  mkdir -p ~/.config/nix

  # netrcファイルを作成
  # umaskで最初から600で作成(一瞬でも緩い権限にしない)
  (
    umask 077
    cat >~/.config/nix/netrc <<EOF
machine your-cache.cachix.org
  password $CACHIX_AUTH_TOKEN
EOF
  )

  # nix.confにnetrcファイルの場所を指定
  if [ ! -f ~/.config/nix/nix.conf ] || ! grep -q "netrc-file" ~/.config/nix/nix.conf; then
    echo "netrc-file = $HOME/.config/nix/netrc" >>~/.config/nix/nix.conf
  fi

  echo "Cachix netrc configured."
else
  echo "CACHIX_AUTH_TOKEN not set. Skipping Cachix authentication."
fi

# Nix開発環境をビルドして現在のシェルにエクスポート
echo "Building and exporting Nix development environment..."
eval "$(nix print-dev-env)"

# direnvを許可して新しいシェルで自動的に環境がロードされるように
echo "Allowing direnv for future shell sessions..."
direnv allow

echo "Setup complete!"

ポイント

シングルユーザーモードでインストール

--no-daemonオプションでシングルユーザーモードでインストールしています。 DevContainerではsystemdが動いていないので、デーモンモードでインストールすると正常に動作しません。

バージョン固定とハッシュ検証

Nixのインストールスクリプトをバージョン固定してハッシュ検証しています。これにより再現性が向上し、サプライチェーン攻撃のリスクも軽減できます。

Remove GPG-signing of releases by edolstra · Pull Request #7411 · NixOS/nix で誰も気にしないからとGPG署名が廃止されましたが、 Claude Code Reviewは気にしているそうです。

VS Codeの非ログインシェル問題

VS Codeのターミナルは非ログインシェルとして起動するので、 .profile.bash_profileは読み込まれません。そのため.bashrcにNixプロファイルの読み込みを追加しています。

direnvとnix-direnvのグローバルインストール

direnvはdevShells.defaultに入れるだけでは不十分です。 direnvが有効になる前にdirenvが存在しないといけないので、 nix profileでグローバルにインストールしています。

nix-direnvを使うことで、 direnv allow後の環境ロードが高速になります。純粋なdirenvだと毎回nix develop相当の処理が走りますが、 nix-direnvはキャッシュを効かせてくれます。

Cachix認証のnetrc方式

Cachixのプライベートキャッシュを使う場合、認証が必要になります。 netrcファイルを使うことで、 Nixが自動的に認証情報を使ってくれます。

netrcファイルはumask 077で作成して、一瞬でも緩い権限にならないようにしています。

flake.nixでグローバルインストール用のパッケージを公開

packages = {
  # グローバルインストール用
  # flake.lockに従った再現可能なインストールが可能
  direnv = pkgs.direnv;
  nil = pkgs.nil;
  nix-direnv = pkgs.nix-direnv;
};

nix profile add .#direnvのように、 flakeから直接パッケージをインストールできるようにしています。 flake.lockに従ってバージョンが固定されるので再現性があります。

.envrc

#!/usr/bin/env bash

# 開発時に使う共有されている環境変数群がもしあれば読み込む。
dotenv_if_exists .env.deve

# 存在していればローカルなカスタムを先に優先して読み込んで、ハックしやすいようにする。
dotenv_if_exists .env.local

# Nix Flakesのロード。
use flake . --accept-flake-config

プロジェクトルートに.envrcを置いておきます。 direnv allow後はディレクトリに入るだけで自動的に開発環境が有効になります。

試行錯誤の記録

DevContainer FeaturesでのNixインストール

最初は features/src/nix at main · devcontainers/features というDevContainer Featuresを使おうとしました。

しかしどうも設定方法が中途半端に制約されていてやりにくそうで、実際試してみても何故かうまくいかなかったので、素直にスクリプトでNixをインストールするように変更しました。

Determinate Systems Installerの問題

最初は公式インストーラの代わりに、 Determinate Systems Installer を試しました。 flakeとかが最初から有効化されているのが魅力的だったので。

しかしこれはsystemdが動いていない環境でどうもうまく動作しませんでした。一応systemdなしを選ぶオプションはあるのですが、公式インストーラの方が慣れているし、結局公式インストーラをシングルユーザーモード(--no-daemon)で使うことにしました。

docker-composeでの環境変数読み込み

最初はdevcontainer.jsonrunArgs.env.localを指定しようとしました。

"runArgs": ["--env-file", ".env.local"]

しかしdocker-compose.ymlを使っている場合、 runArgsの設定は無視されるようです。 docker-compose.ymlenv_fileで指定する必要がありました。

VS CodeのNix拡張機能がNixのLSPを見つけられない

nix-ideはdirenvの環境をうまく認識できないことがあります。 nilをグローバルにインストールすることで回避できます。

nix profile add .#nil