Using Nix with Dockerfiles

Mitchell Hashimoto

DockerfileでNixを使う

Nixは強力なクロスプラットフォームのパッケージ管理ツールです。Nixの利点は多岐にわたりますが、大きな利点のひとつは、一度導入すれば開発環境(LinuxでもMacでも)、CI、本番環境で一貫した環境を得られることです。

私自身、何年もNixを使ってきましたが、最近はDockerfileとNixを組み合わせてDockerイメージをビルドするようになりました。この記事では、このアプローチの利点と、実際の雰囲気が伝わるシンプルな例を紹介します。

念のため、これはNixの入門記事ではありません。読むのにNixの使い方は知らなくても大丈夫ですが、Nixの基本概念やNix言語の解説はしません。Nixを知らなくても読めますし、学ぶ価値があるかどうかを判断する材料にはなると思います。


Dockerfileは簡単、なのにあえてNixを使う理由

Dockerfileはとても手軽です。シェルコマンドを並べるだけですから、そこにNixのようなツールをわざわざ持ち込むのをためらう気持ちはよくわかります。ただ、実用的な理由はシンプルです。Nixを使えば、ローカルマシンでもCIでもDockerでも、常に動く環境がタダで手に入ります1。二重の手間はほとんど、あるいはまったくかかりません。

Nixを使わない典型的なやり方では、ローカル開発、CI、Docker(この記事ではDockerイメージのビルドが主題なので、Dockerを「本番」環境と呼びます)で、それぞれ別々に環境を用意することになります。

  • ローカル開発用には、巨大なREADMEを用意したり、Docker Composeを使ったり、Vagrantを使ったりするかもしれません。

  • 次に、CIでコードを実行・テストする際には、また別にYAMLを書いて環境を作り直します。CI環境は微妙に違うことが多く、必要になるシェルの要件も少し異なります。

  • 最後に本番用として、独自のシェルコマンドを並べたDockerfileを用意します。

それぞれ単体で見ればまあ問題ないのですが、正直どれも少し面倒です。しかも全部合わせると、非常に面倒で壊れやすくなります。機能のためにローカル開発環境とDockerfileを更新して、CIを壊してしまった経験があるのは私だけではないはずです。開発環境は動くようになり、PRに緑のチェック✅がついたのに、本番のランタイムが壊れていた、なんてこともあります。

こうした問題はNixですべて解消できます。Nixが、ソフトウェアのビルドと実行に必要なものの唯一の情報源(single source of truth)になります。そこだけを更新すれば、あとはだいたいどこでもそのまま動きます。ソフトウェアのビルド方法や実行方法を何通りも管理する必要がなくなります。

本気で言っていますが、私は何年も「自分のマシンでは動くのに(ほかでは動かない)」という問題に遭遇していません。一度もです。むしろ最近はNixの全能感をどう抑えるかを真面目に考えているくらいです。久しぶりすぎて、Nixを使っていない人がさまざまな環境でソフトウェアが動かないと嘆いているのを見ても、本当にピンときません。川を前にして「歩いて渡らなければ」と悩む人を、橋の上から自転車で渡りながら眺めているような感覚です。

これは利点のひとつに過ぎませんが、もっとも実用的だと思っています。Nixの純粋主義者の方々2は、純粋性や再現性、強力な言語といった点も挙げるでしょう。それらも確かに正しいのですが、本当に痛いのは環境がとにかく「ちゃんと動いてほしい」という点だと思います。

単体で見れば、NixとDockerを組み合わせるのはDocker単体よりたいてい面倒です。ですが、同じ設定でCIや開発環境まで強化できるという複利的な効果を考えると、NixとDockerの組み合わせはDocker単体よりも楽になり、ほかにも実用的な利点がたくさん出てきます。

この記事ではDockerイメージに絞って解説します。そのため、同じ設定をCIや開発環境などでどう使うかは詳しく触れません。そのあたりはすでに多くの記事があります。ローカル開発環境についてはNix and Direnvを、CIについては私自身のGitHub Actionsワークフローをご覧ください。


大きなアイデア

まずは全体像をざっくり説明し、そのあとコードとシェルコマンドを使った具体例をお見せします。考え方は次のとおりです。

  1. アプリケーションのビルドと実行方法をNixのコードで記述します。
  2. Dockerfileと公式のNixイメージを使い、ほぼ1つのシェルコマンドでNix経由でアプリケーションをビルドします。
  3. マルチステージビルドでFROM scratchを使い、ビルドしたアプリケーションを可能な限り小さいイメージにコピーします。この最終イメージにはNix自体は一切入りません。ビルドのためだけにNixを使ったのです。

ステップ1が再利用できる部分です。この同じコードは開発環境やCI環境の構築にも使えます。前述のとおり、この記事ではその詳細には立ち入りません。

世の中には「NixとDocker」系の記事で、dockerやDockerfileを一切使わずNixだけでDockerイメージをビルドするものもあります。それも可能ですし、まったく問題ありません。ただ、この記事ではあえてDockerfileを使う方法を書きました。こちらのほうが多くの人にとって馴染みがあり、とっつきやすいでしょうし、エコシステムの多くのツールがDockerfileを前提にしているからです。


例:PythonとFlask

実例として、Flaskのウェブアプリケーションを実行するDockerイメージをNixでビルドしてみます。完全なコードはGitHubで公開しています。

Pythonアプリ

ここではFlask自体を学ぶのが目的ではないので、Flaskアプリケーションのコードはsrc/app.pyをご覧ください。だいたい下記のような内容です。ルートページで「Hello World」を返すだけの、Flaskクイックスタートそのままのコードです。

from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello_world():
    return "<p>Hello, World!</p>"

Nix Flake

次に、アプリケーションのビルド方法を記述するNix flakeを作成します。Nix flakeは、開発環境の作成やパッケージのビルド方法などを記述するNix環境の一種です。pyproject.tomlCargo.tomlgo.modpackage.jsonなどに近いものだと考えてください。ただしNix用のものです。

Nix flakeはflake.nixにあり、内容は次のとおりです。

{
  description = "flask-example";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/release-22.11";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let pkgs = import nixpkgs { inherit system; };
      in with pkgs; rec {
        # Development environment
        devShell = mkShell {
          name = "flask-example";
          nativeBuildInputs = [ python3 poetry ];
        };

        # Runtime package
        packages.app = poetry2nix.mkPoetryApplication {
          projectDir = ./.;
        };

        # The default package when a specific package name isn't specified.
        defaultPackage = packages.app;
      }
    );
}

「喧嘩売ってんのか?」(マンガ) Nixを学ぶ前、私はNixのコードを見せられるたびにだいたいこう感じていました。見慣れないコードを初めて見るのは誰でも身構えますし、そもそもNixは特に見た目が美しいわけでもありません。ただ、この記事でNixのコードが出てくるのはこれが最後ですし、この先の内容にはほとんど影響しないので、どうかもう少しだけお付き合いください。

ほとんどはボイラープレートです。重要なのはdevShellpackages.appの行です。devShellはPythonとPoetryが入った開発環境を作ります。packages.appは最終的なパッケージのビルド方法を記述します。NixはPoetryをネイティブにサポートしているので、Poetryアプリケーションをビルドするようそのまま頼めます。主要な言語の多くには、Nixをより使いやすくする同様の高水準ヘルパーが用意されています。

システムにNixをインストールしたら、nix buildを実行してすべてが正しく動くか確認できます。パッケージがビルドされ、result/bin/appでアプリケーションを実行できます。

$ nix build
...

$ result/bin/app
 * Serving Flask app 'src.app'
 * Debug mode: off
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit

ちょっと待ってください、これ、地味にすごいんです。Nixに馴染みがないと、今起きたことのすごさを見逃してしまうかもしれません。result/bin/appの中身を(catしてみて)ぜひ追ってみてください。Pythonアプリを実行するスクリプトですが、依存しているのはNixでインストールされたソフトウェアだけです。ローカルシステムには一切依存せず、競合もしません。ほかのバージョンのPythonやFlaskなどが入っていてもまったく関係ありません。アプリは完全にパッケージ化されているのです。

Dockerfile

では、これをDockerでまとめていきましょう。Dockerfileは次のとおりです。

# Nix builder
FROM nixos/nix:latest AS builder

# Copy our source and setup our working dir.
COPY . /tmp/build
WORKDIR /tmp/build

# Build our Nix environment
RUN nix \
    --extra-experimental-features "nix-command flakes" \
    --option filter-syscalls false \
    build

# Copy the Nix store closure into a directory. The Nix store closure is the
# entire set of Nix store values that we need for our build.
RUN mkdir /tmp/nix-store-closure
RUN cp -R $(nix-store -qR result/) /tmp/nix-store-closure

# Final image is based on scratch. We copy a bunch of Nix dependencies
# but they're fully self-contained so we don't need Nix anymore.
FROM scratch

WORKDIR /app

# Copy /nix/store
COPY --from=builder /tmp/nix-store-closure /nix/store
COPY --from=builder /tmp/build/result /app
CMD ["/app/bin/app"]

これはマルチステージビルドです。まずはnixos/nixをベースにしたbuilderコンテナから始めます。これはnixだけが入ったNix公式のベースイメージです。

このbuilderの中で、まずはnix buildを実行します。

RUN nix \
    --extra-experimental-features "nix-command flakes" \
    --option filter-syscalls false \
    build

これは先ほど実行したものと同じです。追加のフラグは、Nixコマンドでflakesを使えるようにするため(まだexperimental扱いです)と、filter-syscallsオプションで必要に応じてApple SiliconからIntel向けにクロスコンパイルできるようにするためのものです。

次のステップは次のとおりです。

RUN mkdir /tmp/nix-store-closure
RUN cp -R $(nix-store -qR result/) /tmp/nix-store-closure

ここが肝心なステップです。nix store -qR resultは、アプリケーションが必要とするNixディレクトリの一覧をすべて出力します。正確には、アプリケーションが必要とする依存関係のクロージャです。少しわかりにくいかもしれませんので、もう一度言い換えると、アプリケーションの実行に必要な依存関係(ファイルやフォルダ)の最小セットであり、それ以外は文字どおり何も含まないものです。

最後に、from scratchのコンテナを使って最終イメージをビルドします。

FROM scratch

WORKDIR /app

COPY --from=builder /tmp/nix-store-closure /nix/store
COPY --from=builder /tmp/build/result /app
CMD ["/app/bin/app"]

クロージャを/nix/storeにコピーすることで、アプリケーションに必要な依存関係がすべて揃います。次にresultシンボリックリンクを/appにコピーします。そして/app/bin/appをエントリーポイントに設定します。これは先ほどNixファイルをテストしたときにresult/bin/appを実行したのと同じ考え方です。

試してみましょう!

Dockerイメージをビルドして実行します。

$ docker build -t flask-example:dev .
...

$ docker run --rm flask-example:dev
 * Serving Flask app 'src.app'
 * Debug mode: off
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit

デメリット

この方法でDockerイメージをビルドすることにデメリットは多くありませんが、知的誠実さのために、思いつく限りを挙げておきます。

最もわかりやすいデメリットは、Nixの知識が必要になることです。Nixはあまり学びやすいとは言われてきませんでした。ただ近年はドキュメントが大幅に改善され、Zero to Nixのような役立つリソースも増えています。さらにNix Installerを取り巻く状況も、以前と比べてずっと良くなっています。

Nixの習得にはそれなりに時間がかかることを考えると、このデメリットを許容できるのは、CIや開発環境などほかの用途でもNixを使う予定がある場合だと思います。個人的には、一度Nixを覚えてしまえばそうなるのは必然だと思っています。なにしろとにかく素晴らしく便利なのですから。

もうひとつのデメリットは、生成されるDockerイメージのレイヤーが最適ではないことです。単一のRUN nix buildコマンドが、すべての依存関係を含んだ巨大なレイヤーを作ってしまいます。ビルド時間の観点では、依存関係のほとんどがバイナリキャッシュからダウンロードされるため非常に高速です。しかしキャッシュの効率としては最適ではなく、再デプロイのたびに基本的にイメージ最大のレイヤーをランタイム環境が再ダウンロードすることになります。

Nixには、Dockerfileの代わりにネイティブのNixのdockerToolsを使ってより最適なDockerイメージレイヤーを作る方法もあります。ただ、この記事の目的はあくまでDockerfileを使ったアプローチを紹介することです。


次に

思ったより簡単だったのではないでしょうか。Dockerfileは(コメントを除けば)15行未満ですし、Nixを使うことで開発環境やCI環境のビルドに使うのとまったく同じコードを使っているので、ロジックはすべて共有されます。Dockerfile自体はもう変更する必要がありません。

これは昔ながらの普通のDockerfileなので、dockerをはじめ、各種CI/CDツールやPaaSなど、すでに使い慣れているツールともとてもうまく統合できます。

そして最後に、Nixならではのさまざまなメリットも手に入ります。Dockerイメージにはアプリケーションの実行に必要な最小限のファイルだけが含まれ、それ以外は何も入りません。Dockerイメージの中身は再現可能です(メタデータによってイメージ自体のハッシュは変わることがあります)など。これらが重要かどうかは人によりますが、デメリットはなく、タダでついてきます。

先ほども述べたように、このアプローチの利点は、Nixの設定をローカル開発やCIといったほかの環境でも再利用し始めると大きく膨らんでいきます。ですから、すでにNixを使っている方がそれをさらに活かすためにも、これからNixをもっと広く使ってみたい方がその入り口としても、このアプローチをおすすめします。

脚注

  1. もちろんNixの習得にはそれなりに高い初期コストはかかりますが、得られるリターンはそのコストをはるかに上回ると思います。

  2. ❤️ みなさん愛しています。でも、もっと多くの人に届けたいんです!

原文は Mitchell Hashimoto により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。