DockerfileでNixを使う
原文は Mitchell Hashimoto により に公開されました。 このブログを購読する
Nixは強力なクロスプラットフォームのパッケージ管理ツールです。Nixの利点は多岐にわたりますが、大きな利点の一つは、Nixを導入すれば、開発環境(LinuxでもMacでも)、CI、本番環境にわたって一貫した環境を得られることです。
私は何年もNixを使ってきましたが、最近はDockerfileとNixを組み合わせてDockerイメージをビルドするようになりました。この記事では、このアプローチの利点と、実際の使い心地がわかるような基本的な例を紹介します。
すみません、これはNixの入門記事ではありません。 この記事を読むのに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を使うことによる相乗的な利点を考えれば、NixをDockerと併用する方が、Docker単体で使うよりも簡単になり、さらに数多くの実用的なメリットも得られます。
この記事ではDockerイメージに焦点を当てますので、同じ設定をCIや開発環境などで使う方法については詳しくは紹介しません。このトピックについては、すでに数多くのブログ記事があります。ローカル開発環境についてはNix and Direnvを、CIについては私自身のGitHub Actionsワークフローをご覧ください。
大まかなアイデア
まずは大まかなアイデアを高い視点から説明し、そのあとコードとシェルコマンドを使った実際の例をお見せします。アイデアは以下のとおりです。
- Nixコードを書いて、アプリケーションのビルド方法と実行方法を記述する。
- Dockerfileと公式のNixイメージを使い、ほぼ1つのシェルコマンドでNixによるアプリケーションビルドを行う。
- マルチステージビルドで
FROM scratchを使い、ビルドしたアプリケーションを可能な限り最小のイメージにコピーする。この最終イメージにはNixは一切インストールされていません――ビルドのためだけにNixを使ったのです。
ステップ1が再利用可能な部分です。この同じコードは、開発環境やCI環境の構築にも使えます。前述のとおり、この記事ではその詳細には立ち入りません。
世の中にはNixだけを使ってDockerイメージをビルドし、dockerやDockerfileをまったく使わないという「NixとDocker」についてのブログ記事も他にあります。それも可能で、まったく問題ありません。ただ、Dockerfileの方が多くの人にとって馴染みがあり、とっつきにくい印象も少なく、エコシステム上の多くのツールがDockerfileを取り込んで利用しているため、私はあえてDockerfileを使う方法について書くことにしました。
例: PythonとFlask
実際の例として、Nixを使ってFlaskウェブアプリケーションを実行するためのDockerイメージをビルドします。完全なコードは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.tomlやCargo.toml、go.mod、package.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;
}
);
}いきなりFワードを突きつけられた気分ですか?(漫画) Nixを学ぶ前の私は、誰かがNixコードの塊を提示するたびに、たいていこんな気分になっていました。初めて見る馴染みのないコードはたいてい威圧的に感じられますし、そもそもNixは特に見た目が美しいわけでもありません。ただ、これがこの記事でNixコードを見る最後の機会ですし、残りの内容にはほとんど影響しないので、どうかもう少しだけお付き合いください。
このほとんどはボイラープレートです。重要なのはdevShellとpackages.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してみて)確認し、その先をたどってみてください。これは、Nixでインストールされたソフトウェアだけに依存してPythonアプリを実行するスクリプトです。ローカルシステムには一切依存せず、競合もしません。他のバージョンの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ファイルをテストした際にresuilt/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は決して学びやすいとは言えないという評判があります。近年、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活用への入り口として、このアプローチをおすすめします。
脚注
記事をランダムに読む
コメント
ログインしてコメントする