OCaml で書いたプログラムをビルドして実行可能バイナリを得て、それを Docker イメージに固めるという操作を Nix でやる方法についてまとめます。ついでに GitHub Actions を使って CI でビルドした Docker イメージをリリースする方法についても書きます。基本的に opam-nix を使うだけ……と思いきや、細かい落とし穴が色々あるので、それをメインにまとめます。お題として、拙作 ActivityPub 実装の Waq を使います。というか Waq のために調べたものを記事としてまとめています。
#モチベーション
最近、自分の母艦デスクトップの OS を Ubuntu から NixOS に乗り換えました。その結果、OCaml プログラムのパッケージ管理に OPAM を使うよりも Nix を使うほうが何かと便利になったので、自分が管理している OCaml プログラムのビルドには Nix を使うように移行中です。
Nix を使うと OCaml プログラムをビルドすることも当然できますし、開発に必要な種々のソフトウェア(ocaml-lsp-server とか utop とか)を入れることもできます。さらに Docker イメージをビルドすることもできます。というわけで、今まで OPAM や Dockerfile などを使って頑張っていたこれらの作業を、全て Nix に置き換えてしまおうというのが今回のテーマです。
今回 Nix に移行するのは Waq という Web アプリケーションです。Nix に移行する前の構成としては、割と一般的な(はずの)OPAM+dune という形の構成で、Dockerfile を使って Docker イメージをビルドしています。内部的に画像を処理する際に Imagemagick の convert コマンドを使うので、Docker イメージにはそれを一緒にいれてあります。
#opam-nix を導入する
インターネットを回遊したところ、もともと OPAM で管理していた OCaml プログラムを Nix でビルドする際には opam-nix を使うのが定番っぽいです。ということで今回はこれを使います。チュートリアルとして公式のブログ記事があるので、これを見ながら作業します。
プロジェクトのルートディレクトリで nix flake init -t github:tweag/opam-nix#executable
すると opam-nix から提供されるテンプレートを使って flake.nix が錬成されます。これを書き換えていきます。
#ocamlformat のバージョンを .ocamlformat
ファイルと合わせる
OCaml で定番のコードフォーマッタである ocamlformat を使う際は .ocamlformat
に ocamlformat のバージョンを書いておくと再現性が出て便利です。Nix で ocamlformat を入れる際にもこのファイルを勝手に見て入れるバージョンを決めてくれると便利なので、そうします。やり方はびしょ〜じょさんの記事を読むと完全に書いてあるので、その通りにすると動きます。
#pin-depends
をやめ自作のレポジトリを使う
公式の opam repository に入っていないライブラリを使用したい場合などには、.opam ファイルの pin-depends
フィールドを使うことでそのパッケージを pin して使うことができます。opam-nix でも一応 pin-depends
のサポートはありますが --impure
を nix
コマンドに渡す必要があります。
それが嫌な場合は自分でレポジトリを立てて使うという手をとることができます。その場合、自分が作成したレポジトリの情報を opam-nix に伝える必要があります。まず inputs
で flake = false
としてレポジトリを読み込みます:
{
inputs = {
# ...
waq-external-repo = {
url = "github:ushitora-anqou/waq-external-repo";
flake = false;
};
};
# ...
これを buildOpamProject'
の引数に渡します。repos
を使います:
{
# ...
outputs = {
# ...
waq-external-repo,
} @ inputs:
flake-utils.lib.eachDefaultSystem (
system: let
# ...
on = opam-nix.lib.${system};
# ...
scope =
on.buildOpamProject' {
# 公式の opam repository と自作のレポジトリを指定する。
# 公式の方は on.opamRepository に入っている。
repos = [on.opamRepository waq-external-repo];
# ...
}
src
query;
# ...
#OCaml コンパイラを実行時の依存から外す
ocamlopt
でビルドした実行可能バイナリには、ビルドに使用した OCaml コンパイラのパスが埋め込まれる場合があります。この際、Nix はバイナリの中身を検索してこのパスを見つけ、OCaml コンパイラ自体を実行時の依存に含めてしまいます。これを防ぎたい場合は overlay で removeOcamlReferences = true
を設定します:
overlay = final: prev:
# ...
{
${package} = prev.${package}.overrideAttrs (_: {
doNixSupport = false;
removeOcamlReferences = true; # この行を追加する。
});
};
# ...
#Docker イメージを Nix でビルドする
pkgs.dockerTools.buildLayeredImage
を使うと Nix を使って Docker イメージをビルドできます。まず flake.nix に packages.docker
を追加します:
let
# ...
# Waq が使う Imagemagick をビルドする。
# イメージのサイズを小さくするために、必要なサポートのみを
# 有効にする。
customImagemagick = pkgs.imagemagick_light.override {
# cf. https://github.com/NixOS/nixpkgs/blob/a79cfe0ebd24952b580b1cf08cd906354996d547/pkgs/applications/graphics/ImageMagick/default.nix
libjpegSupport = true;
libpngSupport = true;
libtiffSupport = true;
libwebpSupport = true;
libheifSupport = true;
};
in
packages = {
default = main;
docker = pkgs.dockerTools.buildLayeredImage {
name = "ghcr.io/ushitora-anqou/waq";
tag = "dev";
created = "now";
# /tmp は自分で作る必要がある。
extraCommands = "mkdir -m 1777 tmp";
contents =
# / に展開したいパッケージを指定する。
(with pkgs; [
busybox # sh などのコマンドを使えるようにする。
iana-etc # DNS を引けるようにする。
])
++ [
# Waq 本体。
main
];
config = {
Entrypoint = ["waq"];
Env = [
# ocaml-tls 経由で TLS を使えるようにする。
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
# Imagemagick のパスを指定する。
"IMAGEMAGICK_CONVERT_PATH=${customImagemagick}/bin/convert"
];
};
};
}
続いて nix build
コマンドを打って Docker イメージをビルドします。ビルド結果は result
に tar 形式で入るので docker load -i
でロードします:
$ nix build .#docker
$ docker load -i result
$ docker images
# ghcr.io/ushitora-anqou/waq:dev が入っているのが見える。
なお Docker イメージのサイズが期待よりも大きい場合は、OCaml コンパイラ自体が実行時の依存に含まれている可能性があります。一つ前のセクションで紹介した removeOcamlReferences = true
を試してみてください。
#GitHub Actions で Docker イメージをビルドし ghcr.io にリリースする
通常、GHA で Docker イメージをビルド・プッシュする際には docker/build-push-action を使います。しかし今回のケースではビルドを Nix で行いたいため、そのままではこのアクションを使えません。このアクションを全く使わずに直接 docker push
を打つというのも手なのですが、tag や label といったメタデータを docker/metadata-action で抽出して使いたいということを考えると、自力で正しく push を行うのは少し大変です。
そこで、一度 Nix を使って Docker イメージをビルドした上で、そのイメージを FROM
経由で使う Dockerfile を錬成し、それに対して docker/build-push-action を使うことにします。この際、単に nix build .#docker
を打った後に docker/build-push-action を使っても Nix がビルドした Docker イメージを見つけてくれないので、ローカルのレジストリに一度 push してから使うというワークアラウンドを行います。具体的な workflow ファイルは以下のようになります:
# ...
jobs:
build-and-push-image:
# ...
# ローカルの Docker レジストリを立てる。
services:
registry:
image: registry:2
ports:
- 5000:5000
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Docker イメージに付与するためのメタデータを抽出する。
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
# Nix の準備をする。
- uses: cachix/install-nix-action@v27
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v14
with:
name: waq
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
# Nix を使って Docker イメージをビルドする。
- name: Build :dev image
run: |
nix build .#docker
docker load -i result
# ローカルの Docker レジストリにイメージを push する。また、
# docker/build-push-action で使うための Dockerfile を錬成する。
- name: Prepare stuff necessary to build and push the final image
run: |
docker tag ghcr.io/ushitora-anqou/waq:dev localhost:5000/waq:dev
docker push localhost:5000/waq:dev
mkdir ci
echo "FROM builder" > ci/Dockerfile
# docker/build-push-action で最終的な Docker イメージを
# ビルドし ghcr.io に push する。build-contexts を指定する
# ことでローカルのレジストリを見るようにする。
- name: Build and push the final image
uses: docker/build-push-action@v6
with:
context: ci
build-contexts: |
builder=docker-image://localhost:5000/waq:dev
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
#まとめ
この記事は VALORANT Challengers Japan 2025 Split 1 Main Stage Day 6 を見ながら書きました。