技術ブログ記事の再現性を Nix flake で固定する

この記事はAIによって生成されています。内容の正確性にご注意ください。

技術ブログ記事は時間経過とともに動かなくなる。依存ライブラリがメジャーバージョンを上げ、CLIのサブコマンドが消え、Dockerベースイメージがpull不能になる、という現象は誰しも経験がある。これに対して、記事Markdownファイルの隣に Nix flake を置き、読者がそのディレクトリで nix develop するだけで当時の環境を再現できるようにする、というパターンを紹介する。特定の静的サイトジェネレーター(SSG)に依存しない一般的な運用の話で、具体的な実装は本記事の最後に短く触れる程度にとどめる。

TL;DR

  • 技術記事の「腐敗」の多くは、読者と著者の実行環境が時間とともにずれていくことに起因する
  • 対策として、記事 foo.md の隣に foo/ ディレクトリを作り、その中に flake.nix を入れてnixpkgsのコミットまでピン留めしておく
  • 記事本文のコード例と foo/ 内の実ファイルが二重管理にならないように、SSGの ファイルtransclusion機能(include、import、remark directiveなど、使っているSSG次第)で取り込む
  • この記事自身の隣にもcompanionディレクトリが置いてあり、nix develop -c python src/hello.py でPython 3.12系の固定バージョンが立ち上がる

背景: 技術記事はなぜ腐るか

過去記事を読み返したり、読者から「このコード動かないんですが」と指摘されたりすると、腐敗の原因はだいたい次のどれかに行き着く。

  • 言語/ランタイムのバージョン差 — 記事執筆時のPython 3.10と現在のPython 3.13で挙動が変わる、asyncio のAPIがdeprecatedになった、といったパターン
  • サードパーティライブラリの破壊的変更pip install foo が最新版をつかんできて、記事中のimport構造やシグネチャがもう存在しない
  • CLIツールのオプション変更/廃止 — ツールが別ツールに置き換えられ、元コマンドはリポジトリから消えた、みたいなやつ
  • OS/システムライブラリの暗黙依存 — Ubuntuの特定バージョンでしかビルドが通らない、特定の libfoo-dev が必要、など記事には書かれていない依存

記事本文に Python 3.10.5 を使っています と書いておくだけではまったく足りない。読者が再現できる形で 環境そのものをピン留めしたアーティファクト が一緒についていないと、いくら丁寧に書いてもいずれ動かなくなる。

選択肢の比較

環境をピン留めする手段はいくつかあって、本質的には「どの層まで固定するか」の違いでしかない。

アプローチ固定できる層デメリット
バージョン指定のみ記載言語/ライブラリのバージョン(表記上)読者が環境を作る必要がある。OS依存は固定できない
requirements.txt / Cargo.lock を同梱ライブラリのバージョン言語本体とOS依存は固定できない
DockerイメージOS + 依存 + 言語ベースイメージやaptリポジトリが消えるとpull不能になる。環境にDockerが必要
Nix flakenixpkgs スナップショット経由で OS〜言語〜依存まで初見の学習コスト、Nix を入れる必要がある

技術記事の「再現させたい」という目的に対してはNix flakeが一番素直に効く。理由は2つある。

  1. 記事の寿命を延ばすのが目的 で、CIもデプロイも絡まない。ローカルで nix develop が通るだけで十分。
  2. Dockerに比べてレイヤーを覚える必要がない。Dockerイメージを保つにはDockerfileとbase imageのバージョンの両方を気にしないといけないが、flakeはnixpkgsのcommit 1点を固定すれば全部連動して固まる。

Nix自体を入れるハードルはあるが、Determinate Systems installer など公式以外のインストーラでもMac/Linux両方で10分あれば入る。読者に要求するコストはDockerを入れるのと大差ない。

パターン: 記事ファイルの隣にcompanionディレクトリを置く

本題のパターン。記事 foo.md に対して、同じ階層に同名の foo/ ディレクトリを作り、そこに flake.nix と実際に動かすコードを置く。

posts/techblog/.../
├── foo.md
└── foo/
    ├── .gitignore
    ├── flake.nix
    ├── flake.lock      # 任意。narHashレベルで固定したい場合
    └── src/
        └── main.py

記事のURLやslugに1対1対応する位置に置くのがポイント。著者がリポジトリを見たときに「記事と環境がセットになっている」と一目でわかるし、記事を移動・リネームしたときにcompanionディレクトリも一緒に動かしやすい。

SSGによってはMarkdownを再帰的に拾う仕組みの都合で、companionディレクトリ内に .md を置くと意図せず別記事として認識される可能性がある。README.md のようなファイルを置きたくなったら README.txt にしておくか、記事本文側に手順を書いたほうが安全。

flake.nix の要点

再現性のすべてはここに集約される。ポイントは nixpkgs の入力URLを特定コミットのSHAで直書きする こと。ブランチ名(nixos-25.11 など)だけだと時間とともに中身が変わってしまう。

この記事の隣にも最小構成のflakeを置いてあるので、実物を見るのが早い。リポジトリ上の実ファイルをそのまま引いている。

flake.nix

{
  description = "Companion flake for the 'technical blog reproducibility with Nix' post";

  inputs = {
    # Pinned to a specific commit on the nixos-25.11 branch as of 2026-04.
    # Running `nix flake lock` will additionally freeze the narHash in flake.lock.
    nixpkgs.url = "github:NixOS/nixpkgs/54170c54449ea4d6725efd30d719c5e505f1c10e";
  };

  outputs = { self, nixpkgs }:
    let
      forAllSystems = f:
        nixpkgs.lib.genAttrs [
          "x86_64-linux"
          "aarch64-linux"
          "x86_64-darwin"
          "aarch64-darwin"
        ] (system: f (import nixpkgs { inherit system; }));
    in {
      devShells = forAllSystems (pkgs: {
        default = pkgs.mkShell {
          packages = [
            pkgs.python312
            pkgs.uv
          ];

          shellHook = ''
            echo "Reproducible shell for the blog post."
            echo "python: $(python --version)"
            echo "uv:     $(uv --version)"
          '';
        };
      });
    };
}

短く読み下すと以下の3点がポイント。

  • inputs.nixpkgs.url でnixpkgsを特定コミットに固定
  • forAllSystems ヘルパーでx86_64-linux / aarch64-linux / macOS 2種をまとめてサポート
  • devShells.defaultpython312uv を持つshellを提供

これで cd foo && nix develop -c python --version が何年後でも同じパッチバージョンのPythonを返してくれる。

flake.lock の扱い

厳密な再現性を求めるなら flake.lock も一緒にコミットするのが正しい。nix flake lock を1回走らせると、入力それぞれのnarHash(tarball内容のハッシュ)がlockに書き出され、nix develop はlockがあればそちらを優先してfetchする。

ただし、入力URLの時点でコミットSHAを直書きしている場合、lockがなくても事実上同じスナップショットに解決される。段階的に運用を始めるなら、最初はflake.nixのコミットSHA固定だけで様子を見て、後から nix flake lock を走らせてlockを足すのでも十分機能する。本番の検証結果を再現させたい記事では最初からlockも一緒にコミットする方が無難。

.gitignore の1枚追加

companionディレクトリ内は nix buildresult シンボリックリンクやdirenvのキャッシュで汚れやすいので、ディレクトリ単位の .gitignore を1枚追加しておく。

.gitignore

result
result-*
.direnv/
.envrc.local
__pycache__/
*.pyc

リポジトリ全体の .gitignore だけでは拾いきれないケースが多いので、companion単位で明示的に無視リストを置くほうが管理しやすい。

再現の仕方

読者側(あるいは1年後の自分)は以下のようにして記事の環境に入る。

git clone <blog repo>
cd posts/.../foo
nix develop -c python src/main.py

環境を汚さず、Pythonやライブラリのバージョンに関係なく、記事執筆時と同じ結果が得られるのが理想状態。

記事本文とコードの二重管理を避ける

companionディレクトリを置くところまでは簡単だが、実運用で必ずぶつかるのが 記事本文のコードブロックと foo/ 内の実ファイルが徐々にズレる 問題。記事を書き始めた時点では一致していても、後から foo/src/main.py を修正したときに本文のコードブロックを更新し忘れる、あるいはその逆で本文だけ直してcompanionの実ファイルを放置する、というパターンが必ず発生する。

これを解決するには、記事本文から実ファイルを transclusion(取り込み) する仕組みが要る。SSGごとに提供されているinclude機構で実現できる。

  • Jekyllなら {% include_relative src/main.py %}
  • Hugoなら {{% readfile "src/main.py" %}}(シンプルだが、シンタックスハイライトと組み合わせるには自作shortcodeが必要)
  • MDX系(Next.js / Astroなど)なら import code from "./foo/src/main.py?raw" のようなraw import
  • Eleventyなら {% include "foo/src/main.py" %} やカスタムショートコード
  • remark/unifiedベースなら remark-directive::file[./foo/src/main.py] のようなleaf directiveを自作する

どのパターンでも要件は同じ:

  1. 記事から相対パスでファイルを指定できる
  2. コードブロックとしてシンタックスハイライトされる
  3. 長いファイルから一部だけ抜粋できる(GitHub URLフラグメントの #L10-L20 と同じ感覚で指定できると良い)
  4. 範囲超過や存在しないファイルはビルド時に失敗する(サイレントに誤った内容を埋め込まない)

この記事自身でもこのパターンを使っていて、たとえば flake.nixdevShells 定義部分だけを抜き出すと以下のようになる。

flake.nix#L19-L33

    in {
      devShells = forAllSystems (pkgs: {
        default = pkgs.mkShell {
          packages = [
            pkgs.python312
            pkgs.uv
          ];

          shellHook = ''
            echo "Reproducible shell for the blog post."
            echo "python: $(python --version)"
            echo "uv:     $(uv --version)"
          '';
        };
      });

::file[./foo.nix#L19-L33] という書き方で、行範囲を指定した抜粋ができる。この記事では自分のブログ用にremark directiveを自前で用意した(実装は cli/src/embedFile.ts にある)が、前述のとおり既存のSSG内蔵のinclude機構でも同じことはできる。仕組みは何でもよくて、「記事本文と実ファイルを同じソースから描画する」という性質だけ確保されていれば十分

部分抜粋のときの1つの注意

行範囲指定で抜粋する場合は、デフォルトのタイトルに foo.py#L10-L20 のように行範囲を含めておくと読者にも「これはファイルの一部」だとわかりやすい。全文を載せているのか抜粋なのか、パッと見で区別できるのは地味に効く。

トレードオフと限界

このパターンの限界は正直に言っておく。

  • 読者にNixのインストールを要求する。一部の読者にとってはそれだけでハードルになる。記事の題材がメインストリームの言語なら、公式のバージョン管理ツール(pyenv / rustup / nvm)をfallbackとして本文に書いておくほうが親切。
  • nixpkgs が将来消える可能性はゼロではない。極論だがGitHub上のnixpkgsリポジトリが消えたら全部リンク切れになる。とはいえNixOSコミュニティ規模で現実的なリスクは低いと判断している。
  • OS依存のハードウェア(GPUなど)はカバーしきれない。CUDAを絡めた記事は、nixpkgsでCUDAを引くか、NVIDIAドライバの依存関係を別途注記する必要がある。
  • 記事本文と実ファイルの二重管理問題が残るケースもある。transclusionで埋め込んだとしても、「本文の解説部分と実ファイル内のコメント」のような、並走する2つの文章同士のズレは残る。そこは人力で揃えるしかない。

まとめ

  • 技術記事の腐敗は 環境をピン留めできる成果物が記事と一緒に配られていない ことが主因
  • 対策として、記事ファイルの隣にcompanionディレクトリを置き、flake.nix でnixpkgsをコミットSHAまで固定するパターンが軽量で効く
  • 記事本文とcompanion内のコードが二重管理にならないよう、SSGのtransclusion機構でファイルを取り込む。行範囲指定ができるとなお良い
  • 仕組みの詳細はSSGに依存するが、考え方そのものはどの環境でも再利用できる

腐った記事を直すより、最初から腐らせない。そのためのコストとしては、flake.nix 1個を書くくらいは十分安い。

この記事に関するIssueをGithubで作成する

次に読む