👖

pants でスマートな docker build をしよう!

2024/06/30に公開

TL;DR

  • pants は docker をネイティブサポートしている monorepotool の1つであり、git と連携して docker イメージの差分ビルドや、docker イメージ同士の依存性(参照順)を考慮した並列ビルドが可能
  • pants のビルド設定ファイルから、docker buildで外部から変数を与えることの可能な--build-argのパラメータや multi-satge build の対象ステージ指定をする--target、イメージのタグ--tagを渡すことができるため、Dockerfile の処理とデータを分離を促進し、柔軟な docker イメージビルドの実現を支援

はじめに

pants は、 ビルド設定・フローを記述に 完全な python を利用可能な docker や go、java、shell、helm などの言語に加え、多くの linter、formatter をサポートするビルドツールです。

特に、pants は monorepo を上手に管理・運用するためのツールとしてよく知られる monorepotool の中でも docker をネイティブサポートする数少ない monorepotool である特徴をもちます。

本記事では、docker イメージ間にビルド依存性のある複数のイメージに対する、pants を利用したスマートな docker build の方法について紹介します。

この記事を読んで欲しい人

  • (バックエンド・インフラ視点で)monorepotool の技術調査をしている人
  • 自前で Docker ファイル書いて ゴリゴリ docker build するよって人
  • docker イメージ間で依存性のある docker build をスマートにやりたい人

前提知識

本記事では、下記の技術について基本的な知識を前提とします。

  • Linux
  • bash
  • docker
  • python

複数 image の docker build は複雑!? (問題共有パート)

まず、pants の導入する前に複数の docker イメージをビルドしなければならない環境で起こる問題について、認識を合わせたいと思います。

本記事では、例として下記のような2つのプロジェクトをもつ monorepo 考えます。それぞれのプロジェクトは docker build によって複数の docker イメージを自炊して運用していることを想定します。

この時点では、ディレクトリの構造のみをざっくり理解していただければ、問題ありません。ファイルの具体的な中身について気になる人は github のリポジトリをのぞいてみて下さい。

https://github.com/utcarnivaldayo/pants-sample/tree/main

.
├── README.md
├── pants.toml
├── .editorconfig
├── .gitignore
├── .pants.bootstrap
├── example-nest-project
│   ├── README.md
│   ├── app
│   │   ├── README.md
│   │   ├── backend
│   │   │   └── README.md
│   │   └── frontend
│   │       └── README.md
│   └── docker
│       ├── README.md
│       ├── backend
│       │   ├── api
│       │   │   ├── BUILD
│       │   │   ├── Dockerfile
│       │   │   └── extensions.json
│       │   └── batch
│       │       ├── BUILD
│       │       ├── Dockerfile
│       │       └── extensions.json
│       ├── common
│       │   ├── mise
│       │   │   ├── BUILD
│       │   │   └── Dockerfile
│       │   └── ubuntu
│       │       ├── BUILD
│       │       └── Dockerfile
│       └── frontend
│           └── vite
│               ├── BUILD
│               ├── Dockerfile
│               └── extensions.json
└── example-rust-project
    ├── README.md
    ├── app
    │   ├── README.md
    │   ├── backend
    │   │   └── README.md
    │   └── frontend
    │       └── README.md
    └── docker
        ├── README.md
        ├── backend
        │   ├── api
        │   │   ├── BUILD
        │   │   ├── Dockerfile
        │   │   └── extensions.json
        │   └── batch
        │       ├── BUILD
        │       ├── Dockerfile
        │       └── extensions.json
        ├── common
        │   ├── mise
        │   │   ├── BUILD
        │   │   └── Dockerfile
        │   ├── rust
        │   │   ├── BUILD
        │   │   └── Dockerfile
        │   └── ubuntu
        │       ├── BUILD
        │       └── Dockerfile
        └── frontend
            └── vite
                ├── BUILD
                ├── Dockerfile
                └── extensions.json
  • example-nest-projectexample-rust-project の 2つのプロジェクトをもつ
  • 各プロジェクトはアプリケーションコードを管理するappディレクトリと、docker イメージを管理する dockerディレクトリをもつ
  • dockerディレクトリは、用途に応じて commonfrontendbackendの3カテゴリに分かれている
  • commonでは、各プロジェクトでイメージのベースとなる ubuntu イメージ、ubuntu イメージをベースに複数開発言語のバージョンマネージャ mise を導入したmiseイメージ、example-rust-projectではそれらに加え、rust言語のツールチェーンが導入された rustイメージをもつ
  • frontendでは、mise イメージをベースにしてnodeや VSCode 拡張が導入されたフロントエンド向けの開発環境 vite イメージをもつ
  • backendでは、miserust イメージをベースにした、VSCode 拡張などが導入されたバックエンド向けの開発環境apibatch イメージをもつ

上記を整理すると docker イメージのビルド依存関係は次のような図で表されます。
ここで、docker イメージの名前は、開発用を表すdevまたは本番用を表すprodを接頭辞として、カテゴリ(commonfrontendbackend)とサービス名(ubuntumiserustなど)をハイフンで繋いで表します。下記の図では、単純のため開発用イメージdevにのみ注目します。

docker image build dependency

さて、この高々数個の docker image を build すれば良いわけですが、1つずつ依存性を考慮して手動でdocker buildを毎回実行するわけにもいかないので、これらの作業を自動化したいと考えるはずです。したがって、自動化のためには次のような要件が挙げられるかと思います。

  1. 依存性をチェックしてビルド可能(依存先のイメージがなかったらそのイメージもビルド、上流のイメージに変更が入ったら下流のイメージも再ビルド)
  2. 並列ビルドできるところは自動で並列ビルド可能(例: example-nest-projectdev-common-miseに依存している3つの image は並列ビルド可能)
  3. Docker ファイルやイメージ内にCOPYされるファイルなどビルドに使用するファイルの変更検知してビルド可能
  4. 特定のタグ(開発用や本番用、特定プロジェクト)でフィルタしてビルド可能
  5. ビルドで使用するパラメータやイメージタグを外部( shellや環境変数)から与えることが可能

このような機能要件となると、bashでスクリプトを書いたり、makeでやるには少々荷が重そうだと感じて頂けると思います。

本記事では、上記の要件を満たすビルドツールとして、pants が良い感じだったので、紹介させて頂きたいと思います。

pants のセットアップ

本記事では、pants が CI/CD の用途で利用されることやセットアップ方法を容易にすることを考慮して [1]、Windows や macOS に直接インストールは実施せず、Linux 環境(Ubuntu 24.04)に pants の環境を構築します。

Windows や macOS をお使いの方は、docker がインストールされた Ubuntu 24.04の環境を各自ご用意ください。

docker 環境構築の方法については本記事では解説しませんが、おすすめの方法として Multipass を利用した Ubuntu 仮想環境構築の方法について記事を書いているので、よろしければこちらをご参照下さい。

https://zenn.dev/numagotatu/articles/2024-04-07-docker-on-multipass

インストール

Ubuntu 環境における pants のインストール手順について下記に示します。

bash
# 依存パッケージの導入
sudo apt update
sudo apt install ca-certificates curl unzip python3-dev build-essential
# NOTE: build-essential に gcc が含まれています。
# NOTE: python3-distutils は Ubuntu 24.04 にデフォルトで入っている python 3.12 から distutils が廃止になったので不要です。

snap info go
# NOTE: 新しいバージョンがあれば 1.22 の部分を書き換えて下さい

# pants の docker build が go への依存性があるらしく?入れておく
snap install go --classic --channel=1.22/stable

# scie-pants による pants のインストール
curl --proto '=https' --tlsv1.2 -sSfL 'https://static.pantsbuild.org/setup/get-pants.sh' | bash

# バージョンを指定して pnats をインストールする場合
# curl --proto '=https' --tlsv1.2 -sSfL 'https://static.pantsbuild.org/setup/get-pants.sh' | bash -s -- -V 0.12.0

# パスを通す
echo 'export PATH=~/.local/bin:$PATH' >> /home/<ユーザー名>/.bashrc
source ~/.bashrc

https://www.pantsbuild.org/2.21/docs/getting-started/prerequisites

https://www.pantsbuild.org/2.21/docs/getting-started/installing-pants

pants のバージョンは github の scie-pants リポジトリのリリースタグから確認できます。

https://github.com/pantsbuild/scie-pants

以上の導入が完了したら、サンプルの monorepo であるリポジトリを clone して、リポジトリルートでpants --versionを実行しましょう。

https://github.com/utcarnivaldayo/pants-sample/tree/main

pants 設定ファイル

pants.tomlpants を実行するリポジトリルートに下記のような内容で配置します。

https://github.com/utcarnivaldayo/pants-sample/blob/main/pants.toml

  • pants_versionには pants --versionコマンドで確認した値
  • backend_packagesには、pants で使用したい機能について記載します。今回は docker_buildをしたいのでpants.backend.dockerを追加
  • interpreter_constraintsには Ubuntu のシステムにインストールされている python のバージョンを記載
  • root_patterns/はリポジトリルートを表し、そのディレクトリをパスの起点に設定
  • [docker]ブロックについては後ほど説明するので、一旦スルー

詳細は下記のリンクを参照して下さい。

https://www.pantsbuild.org/2.21/docs/using-pants/key-concepts/backends
https://www.pantsbuild.org/2.21/docs/docker
https://www.pantsbuild.org/2.21/docs/using-pants/key-concepts/source-roots

BUILD ファイル

pantsを実行するための下準備ができたので、pants におけるビルド設定を記述するためのBUILDファイルについて作成していきます。ここでは、example-nest-projectにおける例に挙げてまずルートとなるイメージubuntuをビルドする方法について解説します。その後、依存性のあるイメージのビルドについて解説します。

ルートとなるイメージのビルド

まず、example-nest-projectにおけるビルドイメージのルートとなる ubuntu イメージについて Dockerファイルを要約すると次のことを実施します。

  • ベースイメージは 公式の Ubuntu イメージ
  • 変数 UBUNTU_VERSIONUSER_NAMEUSER_PASSWORDdocker build --build-argで渡されることを想定
  • multii-stage build として、ステージ basedevprodをもつ
  • aptパッケージはセキュリティに影響するものといくつかの基本的なパッケージを導入
  • sudo 権限をもつ 一般ユーザーを作成

https://github.com/utcarnivaldayo/pants-sample/blob/main/example-nest-project/docker/common/ubuntu/Dockerfile

docker buildを利用した場合、dev-common-ubuntuは下記のコマンドで実行可能です。

# git リポジトリ内で実施
GIT_CURRENT_BRANCH="$(git branch --show-current)"
GIT_REPOSITORY_ROOT="$(git rev-parse --show-superproject-working-tree --show-toplevel | head -n 1)"
docker build -f ${GIT_REPOSITORY_ROOT}/example-nest-project/docker/common/ubuntu/Dockerfile \
  --target dev \
  -t dev-common-ubuntu:${GIT_CURRENT_BRANCH} \
  --build-arg USER_NAME=numa \
  --build-arg USER_PASSWORD=kotatu \
  --build-arg UBUNTU_VERSION=24.04 \
  ${GIT_REPOSITORY_ROOT}

上記に相当するビルドを pants で実施するにはBUILDファイルにdocker_image関数を記述する必要があります。
dev-common-ubuntuprod-common-ubuntu イメージをビルドするための、BUILDファイルは次のように表されます。

https://github.com/utcarnivaldayo/pants-sample/blob/main/example-nest-project/docker/common/ubuntu/BUILD

BUILDファイルのdocker_build関数について簡単に説明します。

  • name: タスク名とビルドイメージ名を指定
  • description: はタスクの説明を記載
  • tags: ビルド時にこのリストに記載のタグでフィルタしてビルド可能
  • source: ビルドに使用するDockerfileを指定
  • image_tags: イメージタグを設定
  • target_stage: multi-stage build の target を指定
  • dependencies: 依存性のあるファイルやタスクを指定
  • extra_build_args: --build-argの変数と値を与える

file関数は、ビルドに必要な依存性のあるファイルを定義し、ファイル変更検知によるビルドのトリガ対象にしたい場合に使用します。上記の設定では buildBUILDファイルとして設定し、dependencies:buildとして参照しています。

また、extra_build_argsに定義されていないUSER_NAMEはどこから与えているか説明すると、リポジトリルートのpants.tomlbuild_args.pants.bootstrapを利用しています。
pants.tomlbuild_argBUILDファイルのextra_build_argsと異なり、pants における docker build --build-argの共通の値として利用することができます。それらの変数の値は、.pants.bootstrapによってタスクの実行時に決定されます。

https://github.com/utcarnivaldayo/pants-sample/blob/main/pants.toml

https://github.com/utcarnivaldayo/pants-sample/blob/main/.pants.bootstrap

以上で、example-nest-projectにおけるubuntuイメージを pants でビルドする準備が整ったので、pants packageコマンドを利用してビルドします。

bash
# リポジトリルートで実施
pants package example-nest-project/docker/common/ubuntu:dev-common-ubuntu

依存性のあるイメージのビルド

ルートとなるdev-common-ubuntuをビルドすることができたので、dev-common-ubuntuに依存性をもつイメージとして、miseイメージを例に挙げて、別のイメージに依存性のあるイメージのビルドについて、解説したいと思います。
miseイメージの Dockerファイルを要約すると次のことを実施します。

  • ベースイメージは ${TARGET}-common-ubuntu:${GIT_CURRENT_BRANCH}
  • 変数 TARGETGIT_CURRENT_BRANCHUSER_NAMEdocker build --build-argで渡されることを想定
  • multii-stage build として、ステージ basedevprodをもつ
  • aptパッケージはセキュリティに影響するパッケージを導入
  • 複数言語バージョンマネージャmiseを導入

https://github.com/utcarnivaldayo/pants-sample/blob/main/example-nest-project/docker/common/mise/Dockerfile

docker buildを利用した場合、dev-common-miseは下記のコマンドでビルド可能です。

# リポジトリルートで実施
TARGET='dev'
GIT_CURRENT_BRANCH="$(git branch --show-current)"
GIT_REPOSITORY_ROOT="$(git rev-parse --show-superproject-working-tree --show-toplevel | head -n 1)"
docker build -f ${GIT_REPOSITORY_ROOT}/example-nest-project/docker/common/mise/Dockerfile \
  --target dev \
  -t dev-common-mise:${GIT_CURRENT_BRANCH} \
  --build-arg TARGET=${TARGET} \
  --build-arg GIT_CURRENT_BRANCH=${GIT_CURRENT_BRANCH} \
  --build-arg USER_NAME=numa \
  ${GIT_REPOSITORY_ROOT}

上記コマンドに対応するBUILDファイルは下記で表されます。

https://github.com/utcarnivaldayo/pants-sample/blob/main/example-nest-project/docker/common/mise/BUILD

ubuntuイメージとの違いとして、dependenciesdev-common-ubuntuへの依存性が記載されています。
別のイメージへの依存性を記載しておくことで、ubuntuのイメージが無い場合はビルドを実施してから、miseイメージをビルドしてくれるようになります。

# リポジトリルートで実施
pants package example-nest-project/docker/common/mise:dev-common-mise

同様にして、他のdev-common-rustdev-frontend-vitedev-backend-apidev-backend-batchについてもBUILDファイル作成することで、pants でリポジトリ全体の docker build を管理可能になります。

ここまで、docker build に関するBUILDファイルの基本的な設定を扱いましたが、ここで紹介できなかったdocker_image関数のオプションは公式ページを確認してみて下さい。

https://www.pantsbuild.org/2.21/reference/targets/docker_image

ビルドコマンド

リポジトリの docker buildを、pants で一元的に管理できるようになったので、対象を絞ってビルドできるように--tagオプションを利用します。

--tagオプションは--tag='dev-image,prod-image'とした場合にはORの条件、--tag='example-nest-project' --tag='dev-image'のようにした場合はANDの条件でフィルタを実施します。

また、--changed-sinceによるgitの差分検知を利用した差分ビルドについても紹介します。

タグでフィルタしてビルド

example-nest-project

  • example-nest-projectかつdev-imageのタグをもつイメージをビルド
pants --tag='example-nest-project' --tag='dev-image' package ::
# dev-common-ubuntu
# ↓
# dev-common-mise
# ↓ 並列ビルド
# dev-frontend-vite・dev-backend-api・dev-backend-batch
  • example-nest-projectかつdev-imageのタグをもつイメージをビルド
pants --tag='example-nest-project' --tag='dev-common-image' package ::
# dev-common-ubuntu
# ↓
# dev-common-mise
  • example-nest-projectかつdev-front-imageのタグをもつイメージをビルド
pants --tag='example-nest-project' --tag='dev-frontend-image' package ::
# dev-common-ubuntu
# ↓
# dev-common-mise
# ↓
# dev-frontend-vite
  • example-nest-projectかつdev-backend-imageのタグをもつイメージをビルド
pants --tag='example-nest-project' --tag='dev-backend-image' package ::
# dev-common-ubuntu
# ↓
# dev-common-mise
# ↓ 並列ビルド
# dev-backend-api・dev-backend-batch

example-rust-project

  • example-rust-projectかつdev-imageのタグをもつイメージをビルド
pants --tag='example-rust-project' --tag='dev-image' package ::
# dev-common-ubuntu
# ↓ 並列ビルド
# dev-common-mise・dev-common-rust
# ↓ 並列ビルド
# dev-frontend-vite・dev-backend-api・dev-backend-batch
  • example-rust-projectかつdev-imageのタグをもつイメージをビルド
pants --tag='example-rust-project' --tag='dev-common-image' package ::
# dev-common-ubuntu
# ↓ 並列ビルド
# dev-common-mise・dev-common-rust
  • example-rust-projectかつdev-frontend-imageのタグをもつイメージをビルド
pants --tag='example-rust-project' --tag='dev-frontend-image' package ::
# dev-common-ubuntu
# ↓
# dev-common-mise
# ↓
# dev-frontend-vite
  • example-rust-projectかつdev-backend-imageのタグをもつイメージをビルド
pants --tag='example-rust-project' --tag='dev-backend-image' package ::
# dev-common-ubuntu
# ↓
# dev-common-rust
# ↓ 並列ビルド
# dev-backend-api・dev-backend-batch

gitで変更検知をした対象をビルド

  • example-nest-projectかつdev-imageのタグをもち、依存するファイルに最新コミットから差分があった場合に依存関係のあるイメージをすべてビルド
pants --changed-since='HEAD' --changed-dependents='transitive' --tag='example-nest-project' --tag='dev-image' package
# mise の Dockerfile に変更を加えた場合
# dev-common-mise
# ↓ 並列ビルド
# dev-frontend-vite・dev-backend-api・dev-backend-batch
  • example-rust-projectかつdev-imageのタグをもち、依存するファイルにorigin/mainブランチから差分があった場合に依存関係のあるイメージをすべてビルド
pants --changed-since='origin/main' --changed-dependents='transitive' --tag='example-rust-project' --tag='dev-image' package

pants のいまいちなところ

ここまでで、pants が便利なツールだという話をしてきましたが、個人的に pants がいまいちだな...と感じた点について簡単に下記にまとめます。

  • python への依存性があるため、python のバージョン管理コストが発生する点
  • python を pyenv でバージョン管理している場合は別途パスの設定等が必要なこと(またmiseなどの他の python のバージョンマネージャ使った場合は設定方法が不明)
  • 公式ページに事前インストールすべきツールとしてgoが記載されておらず、エラーの解決に時間を要したこと(別の言語を丸ごと追加することになるとは思わなかった...)
  • monorepotool に要求されているプロジェクトの依存性可視化機能が、CLI しかサポートされておらずグラフィカルでない(プラグインも存在するが、古い pants へのバージョン指定あり)
  • VSCode で BUILD ファイルのシンタックスハイライトが効かないこと (python ファイルとして認識されない)
  • VSCode 拡張の suspenders があまり活発に開発されていなさそう

おわりに

本記事では、 pants を利用した docker build の方法についてご紹介しました。

pants を利用することで、docker イメージ間にビルド依存性があるイメージでもスマートにビルド実施を行うことができました。pants についてはまだまだ知らない機能も多いので引き続き調査していきたいです。

一方で、docker build をいい感じにするためだけに pants を導入するのはミドルウェアとしての管理コストが高い気もするので、docker をネイティブサポートしている別のツールも検討しても良いという印象を受けました。

今代わりのツールとして個人的に注目しているのは、docker や k8s に焦点を当てた開発支援ツールである Tilt でこれが良さげな感じがするので調査を進めていきたいと思います。

脚注
  1. あくまで想定するだけで本記事で pants の CI/CD 運用方法は説明しません。本記事では、ローカル環境の Ubuntu 仮想マシン上に pants の環境を構築して、pnats で docker build を実行するところまでをゴールとします。 ↩︎

ぬまごたつ

Discussion