🌍

ちょうどよいビルドツールEarthlyの紹介

2022/06/13に公開

ちょうどよいビルドツールEarthlyの紹介

ビルドツールと呼ばれるものは昔から現代まで使われているMake、JavaやKotlinでは一般的なGradle、Googleで使われていたものをOSS化したBazelあたりが有名です。他にも様々なビルドツールが存在するのですが、知名度は低いものの最近自分が注目しているEarthlyというビルドツールを紹介します。

https://earthly.dev/

まずEarthlyの特徴をBazelやGradleといった他のビルドツールと比較して簡単に紹介します。

Dockerfile + Makefile風のDSL

どんなビルドツールでも基本的には独自DSLでビルド設定を書くことになりますが、EarthlyはDockerfileとMakefileを合体させたようなDSLとなっており、他のビルドツールと比較して分かりやすい方だと思います。このDSLについては後述しますが、このサンプルコードでも雰囲気がわかるかと思います。

https://docs.earthly.dev/basics/part-1-a-simple-earthfile

言語非依存

主にJVM系言語で使われるGradleのように特定の言語に特化したビルドツールではなく、BazelやDockerのビルドと同様にどんな言語のビルドにも使うことが可能です。

ビルドはコンテナの中で行う

コンテナの中でビルドすることによりホストマシンとは隔離された安全な環境でビルドを行います。これはBazelやGradleにはない特徴です。

ビルドキャッシュの考え方がDockerと同様で分かりやすい

ビルドキャッシュは基本的にDockerビルドの考え方と同じなので、コマンドの順序が重要であるというDockerfileの書き方のベストプラクティスの知識をそのまま流用できます。BazelやGradleではビルドキャッシュを正しく効かせるためにはDSLの書き方やオプションへの理解が必要[1]ですが、Earthlyのビルドキャッシュは比較的分かりやすい概念となっています。

ビルドを速くするために何が最も重要か?

Earthly自体の詳しい紹介の前にまず「速いビルド」を実現するために何が重要なのかを解説します。

ビルドツールに求められる機能は大きくこの3つでしょう。

  • ビルドスクリプトの書き味
  • ビルド速度
  • ビルドの再現性

この中でおそらく多くの人が特に求める要素はビルド速度です。ではビルドを速くするために何が重要でしょうか?実はビルドの再現性が最も重要になります。

ビルドキャッシュと再現性

ビルドを速くする方法としてまず思いつくのは単純にマシンスペックを上げることですが、それよりも効果的な方法があります。それは ビルドをしないことです

謎掛けのようですが、マシンスペックを上げればビルドが速く終わるのはコンパイルなどのマシンパワーを必要とする作業をしているからであり、そもそもマシンパワーを使わずにビルドを完了できるならばそれが最速のはずです。これを実現するのがビルドキャッシュです。

ビルドキャッシュとは一般的に一度ビルドした結果をメモリ上やファイルに保存しておき、次回ビルドでは同じビルド作業をする代わりに前回のビルド結果を使うことです。元となるファイルが編集された場合は当然ビルドキャッシュを使わずに再ビルドが必要ですが、ファイルが編集されていない間はキャッシュを使うことでビルド自体を一瞬で終えることが可能です。

一方でビルドキャッシュが原因でよく分からない挙動に遭遇し、 make cleangradle clean を実行した経験がある人も多いと思います。このようなことが起こるのは同じビルドスクリプトやマシンを使用しているのにも関わらずビルド結果が同一にならない、つまりビルドの再現性が低いことが原因です。

しかしこれも本来はおかしなことで、プログラムは書かれた通りにしか動かないのでプログラムが同一であれば全く同じ挙動になるはずです。これをビルドに当てはめると、ビルドに使われるファイルなどの”入力”が同じであれば”出力”となるビルド結果は同じということです。これこそがビルドキャッシュを使うことができる理屈です。

詳しくは拙作ですが過去にBazelの仕組みを解説した記事や、Gradleのインクリメンタルビルドについてのドキュメントのinput, outputについての解説が参考になると思います。

https://zenn.dev/kesin11/books/c86010deb5b8008f394f/viewer/9eb544
https://docs.gradle.org/current/userguide/more_about_tasks.html#sec:up_to_date_checks

再現性を阻む要因

同じビルドスクリプトであってもビルド結果が変わる原因として分かりやすいのは、環境変数とインストール済みのツールのバージョンの違いです。自分のマシンと同僚のマシンでこの2つが完全に一致していることはまずあり得ないでしょう。特にツールに関してはビルド作業以外の開発のためにも各自が色々なものをインストールしているため全く同じ環境はまずあり得ないです。

この問題に対して、Bazelではビルドに使用するツールは原則として全てBazelのために新規インストールすることで対応しています。バージョンに加えてsha256まで厳格に指定されます。

Gradleは基本的にはBazelと比較するとゆるく、例えばJDKはインストール済みのものがあればそれを使用します。しかし、必要であれば各ベンダーが提供しているJDKをバージョン指定で明示的にダウンロードするという厳格な運用も可能です。

https://docs.gradle.org/current/userguide/toolchains.html

コンテナ内でビルドするという解

Bazelのアプローチを見るに、ビルドの再現性を高めるためにはいかにしてビルドを実行するホストマシンとビルド環境を隔離できるかという点に尽きます。Bazelはsandboxという仕組みによってビルド環境の隔離を実現しているのですが、一方でEarthlyはコンテナの中でビルドを実行することでホストマシンとの隔離を実現しています。

コンテナはもともとホストマシンと隔離された環境として実行できることがメリットの1つです。Bazelのように独自の仕組みを新たに用意するのではなく、既に広く利用されているコンテナを利用するという技術的な選択は筋が良いと感じました。

Earthlyの特徴

ここまではEarthlyの紹介の前にビルドおいて重要な要素を説明してきました。ここからやっとEarthlyの特徴を紹介していきます。

MakefileやDockerfile風の分かりやすいビルドスクリプト

まず公式からEarthlyのビルドスクリプトのサンプルをお見せします。

VERSION 0.6
FROM golang:1.15-alpine3.13
WORKDIR /go-example
 
build:
    COPY main.go .
    RUN go build -o build/go-example main.go
    SAVE ARTIFACT build/go-example /go-example AS LOCAL build/go-example

docker:
    COPY +build/go-example .
    ENTRYPOINT ["/go-example/go-example"]
    SAVE IMAGE go-example:latest

https://docs.earthly.dev/basics/part-1-a-simple-earthfile より。

MakefileとDockerfileを合体させたようなDSLですが、基本的に上から順にコマンドが実行されるのでBazelやGradleと比較してかなり理解しやすいはずです。初見であってもDockerfileのノリで何となく読めると思います。

ビルドツールにはタスクベースアーティファクトベースという異なる考え方が存在します。詳しくはリンク先のBazelのドキュメントが詳しいのですがそれぞれを簡単に説明するとこのようになります。

タスクベース(shelscript, Dockerfile)

  • ビルドに必要な手順(How)を記述する
  • 上から順に読めばどのコマンドがどの順番で実行されるのか分かるので理解しやすい
  • ある成果物に依存している成果物が存在する場合、正しくビルドするため順番を明記する必要がある
    • 例:成果物Cをビルドするためには成果物Aと成果物Bが必要な場合、(A, B) → C の順番でビルドされるように明記する必要がある
    • 実際にはMakeやGradleやDockerfileなどはCがAとBに依存していることを記述することで、ビルドする順番はツール側が自動的に決定してくれる機能が存在する

アーティファクトベース(Bazel)

  • 何をビルドするか(What)を記述する
  • どのコマンドがどの順番でビルドされるかかはビルドツールが決定するため詳細は理解しにくい
  • ビルド手順の代わりに成果物を生成するために必要な依存物を全て明記する必要がある
    • 例:成果物Cをビルドするためには、成果物Aと成果物Bが必要と明記する
    • ビルドツールが依存関係を全て把握できるので最終的な成果物からビルド手順の逆算が可能。並列化できる部分は並列ビルドを行うなどビルドツールによる最適化が行われやすい

DockerfileやEarthlyはタスクベースである一方、Bazelはアーティファクトベースです。Bazelのビルドスクリプトは初見ではわかりにくいと思いますが、そもそも考え方が全く別物なのです。GradleはBazelのドキュメントではタスクベースと書かれていますが、個人的にはGradleは両方の考え方を併せ持つビルドツールだと思っています。

言語非依存

Earthlyは言語非依存のビルドツールなのでどんな言語のビルドにも使えます。Dockerfileと同様に、FROM にビルドしたい言語のコンテナを指定すればほとんどの言語向けのビルドを行うことができます。

一方でJVMが得意なGradleやmonorepo構成のNodejsを得意とするTurborepoのようにプロジェクト構成を解析してくれるなどのビルドツール側で特別なサポートはありません。そのため、Earthlyの立ち位置としては特定の言語に特化したビルドツールを置き換えるものではありません。

むしろEarthlyによるビルドの中でこれらの言語特化のビルドツールを使うことによる共存が一般的な形になるでしょう。

直接コンテナイメージを出力できる

Earthlyはどの言語のビルドも行えるので言語ごとの得意不得意はありませんが、コンテナイメージの出力は得意なビルドツールと言えます。例えば以下はnodejsのアプリをビルドするEarthfileの例ですが、最後のSAVE IMAGE multistage_nodejs:latestによってそこの時点でのコンテナの状態を docker build と同様にイメージとして保存できます。

また、この例のbuildとprodのように分割することでDockerfileのマルチステージビルドと同様に必要最低限なパッケージだけがインストールされた軽いイメージ(prod)の作成も可能です。

VERSION 0.6
FROM node:16

build:
  COPY package.json package-lock.json .
  RUN npm ci
  COPY . .
  RUN npm run build
  SAVE ARTIFACT dist

prod:
  FROM node:16-alpine
  WORKDIR /app
  COPY package.json package-lock.json .
  RUN npm ci --production && rm -rf ~/.npm
  COPY +build/dist ./dist

  ENTRYPOINT ["npm", "run", "start"]
  SAVE IMAGE multistage_nodejs:latest # この時点でのイメージが保存される

一方でBazelやGradleなどのビルド環境がコンテナではないビルドツールの場合は、最終的にビルドされた成果物のバイナリだけを運び入れたイメージを作成するという方法が使われます。そのためには以下のようなツールを別途使用することになるため、覚えることが増えることとなります。

https://github.com/bazelbuild/rules_docker

https://github.com/GoogleContainerTools/jib

ローカルにファイルを書き出せる

こちらはDockerfileに対する優位点です。Earthlyはコンテナの中でビルドを行うという特徴を紹介しましたが、それだけであれば通常の docker build と同じです。Earthlyが便利なのはコンテナ内で生成されたファイルを SAVE ARTIFACT という独自の機能によってホスト側に簡単に書き出すことができる点です。

例えばEarthlyの中でTypeScriptのファイルをjsにコンパイルし、それをホスト側に書き出すにはSAVE ARTIFACTAS LOCAL を追加するだけです。

VERSION 0.6
FROM node:16

build:
  COPY package.json package-lock.json .
  RUN npm ci
  COPY . .
  RUN npm run build
  SAVE ARTIFACT AS LOCAL dist # ホスト側のdistディレクトリにも出力される

コード生成のタスク用として便利

この機能が便利な用途の1つはコード生成のタスクです。何らかのDSLからコード生成をするためにMakefileを用意することがあると思いますが、Makefileから実行されるコマンド自体は各ユーザー自身がhomebrewなどでインストールする必要があるかと思います。Earthlyであれば必要なツールのインストールも含めて全てEarthfileに記述するため、ツールのバージョン差異やOSの差異を意識する必要がなくなります[2]

1つの例として、拙作のCIAnalyzerというツールではprotoファイルからTypeScript用の型定義ファイルとBigQuery用のスキーマJSONを生成しているのですが、これにEarthlyを使用しています。protoc のインストールに加えてTypeScriptとBigQuery用のプラグインのセットアップが必要なため開発用のマシンやCI環境が変わるたびにセットアップするのが大変だったのですが、Earthlyに移行してからはどこの環境でも earthly +proto のコマンドだけでファイルを生成できるようになりました。

https://github.com/Kesin11/CIAnalyzer/blob/v5.0.4/Earthfile#L25-L31
https://github.com/Kesin11/CIAnalyzer/blob/v5.0.4/proto/Earthfile

キャッシュの考え方が分かりやすい

前述したように高速なビルドを実現するにはキャッシュの利用が不可欠であり、キャッシュを使えるかどうかがビルド速度を左右すると言っても過言ではありません。

キャッシュの仕組み自体はユーザーに意識させないようにビルドツールによって隠されていることが多く、なぜキャッシュが効いた/効かなかったかを判断するにためにはビルドツールそのものへの深い理解が必要となります。

一方で、このビルドキャッシュをわりと万人に理解しやすい形で実現したのがDockerfileです。Dockerfileのコマンドのうち前回の実行時から変更された行よりあとのコマンドだけが再実行される、というシンプルなキャッシュモデルはほとんどの方が既に理解しているでしょう。

EarthlyのキャッシュもこのDockerfileのキャッシュとほぼ同じです。Earthfileを変更した場合は前回の実行時と同じコマンドまではキャッシュが効き、実際に実行されるのは変更された箇所より後のコマンドの部分だけになります。

先ほどのnodejsのEarthfileを使用して何も変更せずに2回目のビルドを行った場合は以下のように全て cached となりビルドはすぐに完了するといった具合です。

node:16 | --> Load metadata linux/amd64
             context | --> local context .
               +base | --> FROM node:16
             context | --> local context .
               +base | [          ]   0% resolve docker.io/library/node:16@sha256:59eb4e9d6a344ae1161e7d6d8af831cb50713cc631889a5a8c2d438d6ec6aa0f      [██████████] 100% resolve docker.io/library/node:16@sha256:59eb4e9d6a344ae1161e7d6d8af831cb50713cc631889a5a8c2d438d6ec6aa0f
             ongoing | context (5 seconds ago), context (5 seconds ago)
             context | [          ]   0% transferring .:
             context | [██████████] 100% transferring .:
             context | transferred 1 file(s) for context . (1.0 MB, 15077 file/dir stats)
             context | [██████████] 100% transferring .:
              +build | *cached* --> COPY package.json package-lock.json .
              +build | *cached* --> RUN npm ci
              +build | *cached* --> COPY . .
              +build | *cached* --> RUN npm run build
              output | --> exporting output

このキャッシュ方式はBazelやGradleなどと比較するとキャッシュ効率の面ではあまり良いとは言えないのですが、逆にキャッシュによってビルド結果が変わってしまう(壊れる)ことがない点や、Dockerfileによって大半の人がこのキャッシュモデルを既に理解できている点を自分は評価しています。

リモートキャッシュ

一般的にビルドキャッシュはビルドを実行したマシンに保存されていくものですが、これをネットワークで共有することにより別のマシンからもキャッシュを利用できるようにするという考え方があります。

リモートキャッシュによってチーム内でキャッシュを共有すると以下のようなメリットがあると考えられます。

mainブランチでのビルドが高速に終わる

  • mainブランチはCIや多くの人のマシンで実行される機会が多いためキャッシュも最新である可能性が高い
  • キャッシュからの取得だけでビルドを完了できる可能性もありえる

pull-requestのレビュー時にローカルで動作確認するためのビルドが高速になる

  • pull-requestを作成した人の環境で既に1度はビルドされているはずなので、そのときのキャッシュを使わせてもらうことでビルドが高速に終わる

CI用にキャッシュ機構を組む必要がなくなる

  • 一般的に各CIサービスごとのキャッシュ機能を活用してパッケージやビルドキャッシュをキャッシュとして保存するコードを書く必要がある
  • ビルドツール自体にリモートキャッシュ機能が組み込まれていれば、CIサービスごとにキャッシュに関するコードを書く必要がなくなる

近年のビルドツールはリモートキャッシュ機能をデフォルトで備えていることが多く、それだけ有用であると考えられていることが分かります。逆に言えば、新しく登場したビルドツールを評価する際にはリモートキャッシュ機能を備えているかどうかで比較するといいでしょう。

Bazel

Bazelは出自がGoogle内製ツールをOSS化したものであるからか、公式ドキュメントによるとバージョン0.10.0以上の段階で既にリモートキャッシュ機能を備えていたようです。

キャッシュサーバーは通常のHTTPサーバーでも問題ないようでnginxで構築する方法や、あるいキャッシュ用のOSSもあるようです。マネージドのキャッシュ用サービスは無いようですが、Google製ということでGCSを直接キャッシュサーバーとして扱うことも可能です。

自分もBazel v3系のときに実際にGCSをキャッシュ用サーバーとして利用したことがありますので、興味がある方は以下の記事を参照してみてください。

https://zenn.dev/kesin11/books/c86010deb5b8008f394f/viewer/d29c46

Gradle

あまり有名ではないと思いますが、Gradleにもリモートキャッシュの機能が存在しています。

https://docs.gradle.org/current/userguide/build_cache.html#sec:build_cache_configure

Gradleは有料のEnterprise版にてリモートキャッシュ用のサーバーを提供しており、ドキュメントの各所でこのサービスを使うとビルドが高速化されることをアピールしていたりします。

https://gradle.com/gradle-enterprise-solutions/build-cache/

では有料サービスを契約しないとリモートキャッシュは使えないかというとそうでもありません。このリモートキャッシュ用のプラグインのインターフェースは公開されているため、これを実装する形でS3やGCSをキャッシュサーバーとして利用するOSSのプラグインがいくつか公開されています。(むしろForkされまくっててどれを使えばいいのか分からない)

https://plugins.gradle.org/search?term=cache+s3
https://plugins.gradle.org/search?term=cache+gcs

自分も以前に https://github.com/rpalcolea/gradle-gcs-build-cache というプラグインでGCSをリモートキャッシュとして利用したことがありますが問題なく使えていました。ただ執筆時点では最終コミットが2018年とメンテされているかが怪しいため、今では別のプラグインを探すほうがいいかもしれません。

Nodejsの新興ビルドツール

Nx, Turborepo, Rushなど近年発展が目覚ましいNodejsビルドツール界隈ですが、残念ながら自分はこれらに関してはまだエアプであるため、解説は外部のサイトに任せたいと思います。

https://monorepo.tools/#distributed-computation-caching

自分で軽く調べた所感ですが、TurborepoであればVercel、RushであればAzureとそれぞれの開発元がキャッシュサーバーを提供していることが多いようです。Gradleもそうでしたが、このあたりはビジネスがしやすい領域なのでしょうね。

一方でググればそれぞれのツール用に自前でキャッシュサーバーをホスティングするためのOSSや、S3などをキャッシュサーバーとして利用するOSSも見つけることはできました。Nodejs用のビルドツールのうちどれを選択するかの基準として、開発元/コミュニティによるキャッシュサーバーのサポートの手厚さを考慮に入れてもいいかもしれません。

Earthly

さて、満を持してEarthlyのリモートキャッシュ事情について紹介します。Earthlyにおけるキャッシュはdocker buildにおけるそれとほぼ同じであるという説明をしましたが、リモートキャッシュも同様です。というか、裏は同じbuildkitを利用していますのでリモートキャッシュの機能も同じ仕組みになっているのだと思われます。

https://docs.earthly.dev/docs/guides/shared-cache

こちらがリモートキャッシュのドキュメントです。Inline cacheとExplicit cacheという2つのリモートキャッシュ方式が存在し、キャッシュの保存先としてはAWS ECR, Google Artifact Registry, GitHub Container Registryが紹介されています。つまり、Earthlyにおけるリモートキャッシュというのは先述したレイヤーキャッシュをどこかのコンテナレジストリにpush/pullすることとなります。

自分の理解としてはdocker buildxの--cache-toで解説されているinlineとregistryがそれぞれEarthlyのinlineとexplicitに対応しています。最新のdockerでbuildxに馴染みがある方であればおおよそピンと来ると思いますが、そうでない方はこちらの記事のbuildxの各キャッシュ方式を参考にしてください。

https://roy-n-roy.nyan-co.page/Docker/BuildKit/

Earthlyでリモートキャッシュを利用するのはDockerと同様で非常に簡単です。自分たちのチームが最も利用しているクラウドサービスのコンテナレジストリに対してCI環境と各自のローカルマシンからpush/pullできるように認証するだけでOKです。ほぼdockerのレイヤーなのでリモートキャッシュのサイズは大きくなりがちですので、コンテナレジストリはCIと同じクラウドサービスを利用したり[3]、近いリージョンを選択するのが良いと思います。

1つの事例として、自分がEarthlyを利用しているOSSでは最終的なイメージをGHCRで公開していますが、それとは別にリモートキャッシュにもGHCRを利用しています。このようにGHCRにdocker loginし、Earthlyのオプションに --push --max-remote-cache --remote-cache=ghcr.io/ORG/CACHE_IMAGE_NAME を追加することでCIでのビルド時にリモートキャッシュへの書き出しが実現できます。

https://github.com/Kesin11/CIAnalyzer/blob/v5.0.4/.github/workflows/docker.yml#L27-L36

まとめ

本来はもっと簡単にEarthlyの様々な機能を紹介するつもりだったのですが、Earthlyの特徴を説明するにはBazelやGradleといった他のビルドツールや、ビルドキャッシュの仕組みから説明する必要があると思っていたら結果としてEarthly自体の解説は半分程度になってしまいました。細かい機能の紹介はまたいずれ。

EarthlyはMakefileよりも現代的なビルドツールの機能を備えつつ、Bazelよりは学習コストが低く済み、Gradleとは異なりJVM以外の言語にも使えるという"ちょうどいい"ビルドツールであると感じています。何よりDSLがほぼDockerfileなので圧倒的にとっつきやすい。

まだまだ絶賛開発中なので細かい機能は今後増えたり変更されると思いますが、今回紹介した根本的な部分はそう変わらないと思います。Makefileよりも高機能なビルドツールを求めつつBazelはハードルが高すぎると感じた人はぜひ試してみてください。

脚注
  1. とはいえBazelやGradleのビルドキャッシュの概念は効率的なビルドに必須の概念なので理解しておく価値はあります。 ↩︎

  2. ビルドに使用するコンテナが指定するためツールのインストール処理にmacOS/Linux, apt/yumなどを考慮した分岐が不要になります。 ↩︎

  3. GitHub ActionsであればGHCR、AWSであればECR、GCPならArtifact Registryといった組み合わせです。 ↩︎

Discussion