技術ブログ記事の再現性を Nix flake で固定する
技術ブログ記事は時間経過とともに動かなくなる。依存ライブラリがメジャーバージョンを上げ、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 flake | nixpkgs スナップショット経由で OS〜言語〜依存まで | 初見の学習コスト、Nix を入れる必要がある |
技術記事の「再現させたい」という目的に対してはNix flakeが一番素直に効く。理由は2つある。
- 記事の寿命を延ばすのが目的 で、CIもデプロイも絡まない。ローカルで
nix developが通るだけで十分。 - Dockerに比べてレイヤーを覚える必要がない。Dockerイメージを保つにはDockerfileとbase imageのバージョンの両方を気にしないといけないが、flakeはnixpkgsのcommit 1点を固定すれば全部連動して固まる。
Nix自体を入れるハードルはあるが、Determinate Systems installer など公式以外のインストーラでもMac/Linux両方で10分あれば入る。読者に要求するコストはDockerを入れるのと大差ない。
パターン: 記事ファイルの隣にcompanionディレクトリを置く
本題のパターン。記事 foo.md に対して、同じ階層に同名の foo/ ディレクトリを作り、そこに flake.nix と実際に動かすコードを置く。
記事のURLやslugに1対1対応する位置に置くのがポイント。著者がリポジトリを見たときに「記事と環境がセットになっている」と一目でわかるし、記事を移動・リネームしたときにcompanionディレクトリも一緒に動かしやすい。
SSGによってはMarkdownを再帰的に拾う仕組みの都合で、companionディレクトリ内に .md を置くと意図せず別記事として認識される可能性がある。README.md のようなファイルを置きたくなったら README.txt にしておくか、記事本文側に手順を書いたほうが安全。
flake.nix の要点
再現性のすべてはここに集約される。ポイントは nixpkgs の入力URLを特定コミットのSHAで直書きする こと。ブランチ名(nixos-25.11 など)だけだと時間とともに中身が変わってしまう。
この記事の隣にも最小構成のflakeを置いてあるので、実物を見るのが早い。リポジトリ上の実ファイルをそのまま引いている。
flake.nix
短く読み下すと以下の3点がポイント。
inputs.nixpkgs.urlでnixpkgsを特定コミットに固定forAllSystemsヘルパーでx86_64-linux / aarch64-linux / macOS 2種をまとめてサポートdevShells.defaultがpython312とuvを持つ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 build の result シンボリックリンクやdirenvのキャッシュで汚れやすいので、ディレクトリ単位の .gitignore を1枚追加しておく。
.gitignore
リポジトリ全体の .gitignore だけでは拾いきれないケースが多いので、companion単位で明示的に無視リストを置くほうが管理しやすい。
再現の仕方
読者側(あるいは1年後の自分)は以下のようにして記事の環境に入る。
環境を汚さず、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を自作する
どのパターンでも要件は同じ:
- 記事から相対パスでファイルを指定できる
- コードブロックとしてシンタックスハイライトされる
- 長いファイルから一部だけ抜粋できる(GitHub URLフラグメントの
#L10-L20と同じ感覚で指定できると良い) - 範囲超過や存在しないファイルはビルド時に失敗する(サイレントに誤った内容を埋め込まない)
この記事自身でもこのパターンを使っていて、たとえば flake.nix の devShells 定義部分だけを抜き出すと以下のようになる。
flake.nix#L19-L33
::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個を書くくらいは十分安い。