🌏

gRPC サーバのビルドに Earthly を使ってみよう

2022/07/26に公開3

Earthly というビルドツールが (自分の中で) 俄かに話題になっています.自分で手を動かしてサンプルコードを作ってみたので,それを基に簡単に機能を紹介したいと思います.題材は Go + gRPC です.

Earthly って何?

Earthly は Makefile と Dockerfile を足したような書き味のビルドツールで, Makefile のように複数のタスクを定義し,タスクの中で Dockerfile を書くような感じでテストのような命令を実行したり,イメージを生成して保存したりできます.

すでに他の方が Earthly を紹介している記事もあるのでぜひご覧ください.
https://zenn.dev/kesin11/articles/7f4eed7cabf38d

Earthly の大きな特徴は "Makefile + Dockerfile 風な構文の馴染みやすさ", "タスクをコンテナ上で実行することによる可搬性の高さ", "ビルドキャッシュの仕組みの簡単さ (Docker と同じ考え方なので理解しやすい)" の3つです.

感触的には Dockefile で (複数のイメージやコンテナを駆使しながら) いい感じにタスクを定義できるようになるツールって感じなので, Docker を利用してきた開発者であれば,すぐに使えるようになるのではないかなと思います.

言語非依存のツールなので, Docker さえ使えれば基本どんなプロジェクトでも有用ですが,特にビルドツールの可搬性を重視したいプロジェクトや Bazel を入れるほどではないが Makefile だと物足りないというプロジェクトに良いかもしれません (Bazel触ったこと全くないけど).Makefile + Dockerfile でビルドしていたようなプロジェクトは Earthly (Earthfile) 単体で置き換えることができそうです.

使ってみよう

ということで,実際に Earthly を使ってみて使い心地を試してみましょう.
最近 Go + gRPC でプログラムを書く機会があったので,そのプロジェクトを単純化して Earthly を導入してみました.

https://github.com/emiksk/earthly-example/tree/main/grpc

今回の上のコードを参考に Earthfile の書き方を説明していきます.

ディレクトリ構成

grpcディレクトリの構成は以下のようになっています.

grpc
├── .earthlyignore
├── Earthfile
├── example.proto
├── go.mod
├── go.sum
├── main.go
├── main_test.go
└── pb                     # pbディレクトリは Earthfile のターゲットによって生成される
    ├── example.pb.go
    └── example_grpc.pb.go

example.proto にはシンプルな EchoService のスキーマを定義しています.
https://github.com/emiksk/earthly-example/blob/39f06b95418e94c9f8496387e84a777fee6a922e/grpc/example.proto

main.goEchoService の提供する Echo メソッドの中身を実装し, gRPCサーバとして稼働します.
https://github.com/emiksk/earthly-example/blob/39f06b95418e94c9f8496387e84a777fee6a922e/grpc/main.go

そして, Earthfile には Protobuf のスタブコード生成 (pbディレクトリに出力される) や Go で書かれたコードのビルド, Docker イメージのビルド, Lint やユニットテストのターゲット (タスク) が定義されています.

Earthfile を見ていくぞい

それでは Earthfile の中身を見てみましょう.

ばーん
https://github.com/emiksk/earthly-example/blob/39f06b95418e94c9f8496387e84a777fee6a922e/grpc/Earthfile

ターゲットごとに見ていきましょう.

base (冒頭部分)

個別のターゲットに属さない冒頭の共通部分は base と呼ぶそうです. 最初に, この base の箇所で何をしているかを見ていきます.

https://github.com/emiksk/earthly-example/blob/39f06b95418e94c9f8496387e84a777fee6a922e/grpc/Earthfile#L1-L3

VERSION 0.6 の部分は,この Earthfile で想定する Earthly のバージョンです.このバージョンによってどの機能が有効になるか制御されます.
VERSIONEarthfile の先頭に書きます.現在はオプショナルですが,今後必須となる予定らしいので,必ず書いておきましょう.
https://docs.earthly.dev/docs/earthfile#version

FROM golang:1.17-buster はこの Earthfile のデフォルトのベースイメージです.
Earthfile で定義されたターゲットは基本的にコンテナで実行される[1]ため,そのコンテナのベースとなるイメージを指定する必要があります.ターゲットの外に書かれた FROM はターゲットのデフォルトのベースイメージになるので, ターゲットで明示的に FROM を指定しない場合はこのイメージが利用されます.今回の例では, go のビルドやツールのインストールをするので golang:1.17-buster をデフォルトのベースイメージに指定しています.
https://docs.earthly.dev/docs/earthfile#from

後述しますが, Earthly と Dockefile の FROM の異なる点として, Earthly の FROM ではイメージだけでなく, Earthly のターゲットを指定できます.これによって,あるターゲットの結果を別の複数のターゲットから再利用できる,つまりビルドキャッシュを利用できます.

WORKDIR /work はデフォルトの作業ディレクトリの指定です.
https://docs.earthly.dev/docs/earthfile#workdir-same-as-dockerfile-workdir

proto-goターゲット

proto-goターゲットでは, protoc や protoc-gen-go, protoc-gen-go-grpc などのツールをインストールし, Protobuf ファイルからスタブコードを生成します.

https://github.com/emiksk/earthly-example/blob/39f06b95418e94c9f8496387e84a777fee6a922e/grpc/Earthfile#L6-L29

ターゲット単体で見ると,やっていることはほとんど Dockerfile ですが, SAVE ARTIFACT という特殊なコマンドが使われています.SAVE ARTIFACT コマンドはターゲットのコンテナ (ビルド環境) 内のファイルやディレクトリをアーティファクト環境にコピーするコマンドです.アーティファクト環境にコピーされたファイルは,他のターゲットから参照できます.つまり, Dockefile のマルチステージングビルドみたいなことをこの仕組みで実現できます.また, AS LOCAL ... を付けることで,アーティファクト環境だけでなくローカル (ホスト) にも対象のファイルをコピーできます.ターゲットの生成物がローカルに必要な場合は AS LOCAL ... を指定してください[2]
https://docs.earthly.dev/docs/earthfile#save-artifact

このケースでは, protoc を用いて生成されたファイルをアーティファクト環境の /pb ディレクトリとローカル環境のカレントディレクトリにコピーしています.

実際に実行してみると以下のような結果が出力されます.

$ earthly +proto-go

 1. Init 🚀
————————————————————————————————————————————————————————————————————————————————

           buildkitd | Found buildkit daemon as docker container (earthly-buildkitd)


 2. Build 🔧
————————————————————————————————————————————————————————————————————————————————

  golang:1.17-buster | --> Load metadata linux/amd64
             context | --> local context .
               +base | --> FROM golang:1.17-buster
               +base | [          ]   0% resolve docker.io/library/golang:1.17-buster@sha256:7b44fe9b029cb9160c96471f689ff2a5c9b3a60edb[██████████] 100% resolve docker.io/library/golang:1.17-buster@sha256:7b44fe9b029cb9160c96471f689ff2a5c9b3a60edb732ed95bfa4681852fdbda
               +base | --> WORKDIR /work
           +proto-go | --> RUN apt-get update && apt-get install -y wget unzip
                           ...
           +proto-go | --> RUN if [ "$TARGETARCH" = "amd64" ]; then wget -O protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOC_VERSION/protoc-$PROTOC_VERSION-linux-x86_64.zip ;else wget -O protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v$PROTOC_VERSION/protoc-$PROTOC_VERSION-linux-aarch_64.zip ;fi
                           ...
           +proto-go | --> RUN unzip protoc.zip -d /usr/local/
                           ...
           +proto-go | --> RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v$PROTOC_GEN_GO_VERSION
           +proto-go | go: downloading google.golang.org/protobuf v1.26.0
             ongoing | +proto-go (5 seconds ago)
           +proto-go | --> RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v$PROTOC_GEN_GO_GRPC_VERSION
           +proto-go | go: downloading google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0
           +proto-go | go: downloading google.golang.org/protobuf v1.23.0
           +proto-go | --> COPY example.proto /work
           +proto-go | --> RUN protoc --proto_path=/work --go_out=/work --go-grpc_out=/work /work/example.proto
           +proto-go | --> SAVE ARTIFACT /work/pb +proto-go/pb AS LOCAL ./
              output | --> exporting outputs
              output | [██████████] 100% copying files
              output |

 3. Push ⏫ (disabled)
————————————————————————————————————————————————————————————————————————————————

To enable pushing use

                earthly --push ...



 4. Local Output 🎁
————————————————————————————————————————————————————————————————————————————————

Artifact github.com/emiksk/earthly-example/grpc:main+proto-go/pb output as pb


========================== 🌍 Earthly Build  ✅ SUCCESS ==========================

実行後にローカルの環境を見ると pb ディレクトリが生成されています.

このターゲットではスタブコードの生成に使うツールのバージョンを ARG コマンドで指定することで固定していますが, これらは Dockerfile の ARG と同じように実行時に上書きできます.

$ earthly +proto-go --PROTOC_GEN_GO_GRPC_VERSION=1.2.0

ARG の値を変更するとレイヤーが変わるため,それ以降のレイヤーはキャッシュがヒットしなくなります. ARG はできるだけ使用する直前に定義するようにしましょう.
また,Earthlyには組み込みの ARG もいくつか用意されており, proto-go ターゲット内の TARGETARCH もそのひとつです.
https://docs.earthly.dev/docs/earthfile/builtin-args

deps ターゲット

deps ターゲットでは proto-go ターゲットで生成されるスタブコードや go.mod で指定している依存ライブラリを取得します.このターゲットはその他のターゲットのベースイメージとして利用されており,これによってビルドキャッシュを効率的に活用できます.

https://github.com/emiksk/earthly-example/blob/39f06b95418e94c9f8496387e84a777fee6a922e/grpc/Earthfile#L31-L37

このターゲットの最初で COPY コマンドを利用していますが,この時 +proto-go/pb という Dockerfile では見ない書き方をしています.これが proto-go ターゲットのアーティファクト環境のファイル (アーティファクトとも呼ばれる) を参照する方法です.
https://docs.earthly.dev/docs/earthfile#copy

build ターゲット

build ターゲットでは, deps ターゲットをベースイメージとして,ローカルのファイルを全てコピーしバイナリにビルドしています.

https://github.com/emiksk/earthly-example/blob/39f06b95418e94c9f8496387e84a777fee6a922e/grpc/Earthfile#L39-L44

ここでは,デフォルト (golang:1.17-buster) ではなく, FROMdepsターゲットを指定しています.つまり, build ターゲットのビルド環境には Protobuf のスタブコードや go.modgo.sum, 依存ライブラリが含まれている状態です.

その状態で go build コマンドでバイナリを生成し, SAVE ARTIFACT コマンドでアーティファクト環境とローカルにコピーしています (少しわかりにくいですが,アーティファクト環境の /server はディレクトリでなくバイナリファイルです).

このターゲットではローカルのカレントディレクトリをビルド環境にコピーしていますが, ローカルに .earthlyignore を配置しているので,一部のファイルは無視されます.
https://github.com/emiksk/earthly-example/blob/39f06b95418e94c9f8496387e84a777fee6a922e/grpc/.earthlyignore

https://docs.earthly.dev/docs/earthfile/earthlyignore

docker ターゲット

docker ターゲットでは,実行用のイメージ (ここでは debian:buster) をベースにビルドしたバイナリを配置してプログラムを実行できるイメージを生成します.

https://github.com/emiksk/earthly-example/blob/39f06b95418e94c9f8496387e84a777fee6a922e/grpc/Earthfile#L46-L53

ここで使用されている SAVE IMAGE コマンドは,このターゲットで生成されたイメージをローカルやリモートのコンテナレジストリに保存するためのコマンドです. 基本的には引数で与えた名前でローカルにイメージを保存しますが, --push オプションを用いることで,イメージをリモートのコンテナレジストリにプッシュできます. ただし, -push オプションを指定してもデフォルトではプッシュされず, 実行時にも --push オプションを指定してあげる必要があります.

$ earthly --push +docker

https://docs.earthly.dev/docs/earthfile#save-image

lint ターゲット

lint ターゲットでは, staticcheckgo vet を用いてコードの静的解析を行っています.

https://github.com/emiksk/earthly-example/blob/39f06b95418e94c9f8496387e84a777fee6a922e/grpc/Earthfile#L55-L61

test ターゲット

test ターゲットでは, deps ターゲットをベースイメージとしてユニットテストの実行を行っています.

https://github.com/emiksk/earthly-example/blob/39f06b95418e94c9f8496387e84a777fee6a922e/grpc/Earthfile#L64-L68

all ターゲット

all ターゲットでは, BUILD コマンドで複数のターゲットを指定して実行しています.

https://github.com/emiksk/earthly-example/blob/39f06b95418e94c9f8496387e84a777fee6a922e/grpc/Earthfile#L70-L75

BUILD コマンドは, ターミナルでターゲットを指定して earthly コマンドを実行した時と同じように動きます.
https://docs.earthly.dev/docs/earthfile#build

複数のターゲットが実行される時, Earthly はそれぞれのターゲットをなるべく並列に動かそうとします. 実行結果を見ると, 異なるターゲットが並列に実行されている様子がわかります.

その他の機能

今回の例では紹介できませんでしたが, 他にも便利な Earthly の機能として IMPORT コマンドと WITH DOCKER があります.

IMPORT コマンド

Earthly は Earthfile を分割し, 他の Earthfile から FROM コマンドや BUILD コマンドで参照できます.

some-target:
    FROM ./other-service/other-dir+other-target
    ...

IMPORT コマンドを用いると, この参照にエイリアスをつけられます.

IMPORT ./other-service/other-dir AS other-earthfile

some-target:
    FROM other-earthfile+other-target
    ...

モノレポでのプロジェクトに役立ちそうです. ちなみに, 参照する Earthfile は別のリポジトリでも構いません. たとえば, github.com/emiksk/earthly-example/grpc:main+docker といった具合です.

https://docs.earthly.dev/docs/earthfile#import
https://docs.earthly.dev/docs/guides/target-ref#target-reference
https://docs.earthly.dev/basics/part-5-importing

WITH DOCKER コマンド

WITH DOCKER コマンドを利用すると, そのターゲットの中で Docker を起動し, コンテナを動かすことができます. よくあるユースケースとしては, 結合テスト用や動作確認のための開発環境用の docker-compose.yaml を用意し, 別のターゲットで生成したイメージを指定して複数のコンテナを起動するターゲットを作成できたりします.

integration-tests:
    FROM earthly/dind:alpine
    COPY docker-compose.yml ./
    WITH DOCKER --compose docker-compose.yml --load tests:latest=+test-setup
        RUN docker run --network=default_go/part6_default tests:latest
    END

https://docs.earthly.dev/docs/earthfile#with-docker
https://docs.earthly.dev/basics/part-6-using-docker-with-earthly

おわりに

という感じで Earthly 入門をやってみました. 学習コストが低く, やりたいことが大体できるツールなので個人的にはかなり気に入ってます. 実際にプロジェクトで使ってみてもう少し感触を探りたいところですね.

CIでの利用編も書こうと思いましたが, 長くなっちゃいそうなので次回に回します!🌎

脚注
  1. 例外として LOCALLY コマンドというものが存在し,このコマンドで定義されたターゲットはホスト上で実行できます.が,あんまり多用しない方が良いでしょう.https://docs.earthly.dev/docs/earthfile#locally ↩︎

  2. SAVE ARTIFACT コマンドは,ターゲットが FROM で指定された場合は AS LOCAL ... を指定してもローカルにファイルが保存されない仕様になっています.ローカルにファイルをコピーしたい場合は, earthly +build のように直接ターゲットを指定して実行するか, BUILD コマンドでターゲットを指定しましょう. ↩︎

Discussion

山田(ymd)山田(ymd)

Earthly 良さそうですね。
素敵な記事ありがとうございます。

ところで、引用元の Earthly 公式のドキュメントにも書かれている以下の部分が、自分の中で理解できていなくモヤッとしています。

Earthly と Dockefile の FROM の異なる点として, Earthly の FROM ではイメージだけでなく, Earthly のターゲットを指定できます.

It works similarly to the classical Dockerfile FROM instruction, but it has the added ability to use another target's image as the base image.

https://docs.earthly.dev/docs/earthfile#from

実際には、普通の Dockerfile でも FROM に前のステージの名前を渡せるので、何が Earthly の独自の機能(メリット)なんでしょう?

Optionally a name can be given to a new build stage by adding AS name to the FROM instruction. The name can be used in subsequent FROM and COPY --from=<name> instructions to refer to the image built in this stage.

https://docs.docker.com/engine/reference/builder/#from

拙著で恐縮ですが、Dockerfile で build や test を含む CI を実施する方法を書いておりまして、その中でも中間ステージをビルドキャッシュとして使っています。
https://zenn.dev/ymd_h/articles/490b95672510bb

emikskemiksk

コメントありがとうございます!

実際には、普通の Dockerfile でも FROM に前のステージの名前を渡せるので、何が Earthly の独自の機能(メリット)なんでしょう?

おー,これは知りませんでした.Docker でも FROM にステージを指定できるのですね.
そうなると Earthly でのメリットを強いて挙げるとすると,分割された Earthfile (リモート含む) のターゲットを指定できるとかかもしれません.
Earthly の <target-ref> は同じ Earthfile に限定されないですが,Dockerfile が異なるファイルのステージを指定できないのであればメリットと言えそうです (それ Dockerfile でもできますよ案件だったらすみません).
https://docs.earthly.dev/docs/guides/target-ref#target-reference

山田(ymd)山田(ymd)

回答ありがとうございます。

分割された Earthfile (リモート含む) のターゲットを指定できるとかかもしれません.

なるほど、確かに Dockerfile では、ファイル分割はサポートされていない気がしますので、Earthlyの利点ですね。
(Dockerfile でも一旦ビルドして tag つけておけば、指定できなくはないですが、二度手間ですね。)