blog.anqou.net
rss
author
tags

Nix で OCaml コードをビルドし Docker イメージに固めて GitHub Actions でリリース

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 のサポートはありますが --impurenix コマンドに渡す必要があります。

それが嫌な場合は自分でレポジトリを立てて使うという手をとることができます。その場合、自分が作成したレポジトリの情報を opam-nix に伝える必要があります。まず inputsflake = 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 を見ながら書きました。