Runner in the High

技術のことをかくこころみ

Earthlyでコンテナ時代のビルド環境を味わう

【これはUnipos Advent Calendar 2021の2日目の記事です】 https://github.com/earthly/earthly/raw/main/img/logo-banner-white-bg.png

つい最近、EarthlyというDockerコンテナベースのビルドツールで、自分の開発しているGoのアプリケーションのMakefile/Dockerfile/docker-compose.yamlを置き換えたのでそれを記事にしてみる。

Earthlyとは

github.com

めちゃくちゃ雑に言うとDockerイメージをベースにしたビルドツール。

できることとしてはGoogleが作っているBazelに近い*1が、書き味はMakefile+Dockerfileに近く*2、独特の文法が少ない雰囲気。当然、言語やフレームワークに依存しない。まるでローカル環境でビルドをしているかのようにシームレスにコンテナ環境でビルドを実行できる。

Earthlyは書き味こそDockerfileと似ているが、Dockerイメージを作ることが目的のDockerfileよりも汎用性が高い。Dockerイメージの作成以外にも、コンテナ環境でのテストの実行、ファイルの生成など、イメージの定義だけではなく実行するコンテナ上での操作も定義できる。この辺はMakefileのエッセンスを取り入れていると言える。

他にも、Dockerよろしくレイヤキャッシュがちゃんと使えたり、Makefileでいうターゲット定義の単位でビルドグラフを作ってくれて、勝手に並列にできそうなところは並列化したりしてくれたりと嬉しい要素はたくさんあるが、とにかくまずはEarthfileの雰囲気を見る方が早い。

Earthfileの雰囲気

実際のファイルはここに置いてある。 github.com

アプリケーションのビルド

ほとんどMakefileと雰囲気は同じ。このあたりはDockerfileで書かれていたところ。

FROMCOPYで依存関係を定義でき、依存するイメージやアーティファクトがなければ自動でターゲットを実行し成果物を生成する。ここでは earthly +image とやると上から順番にターゲットが実行され、最終的にアプリケーションのDockerイメージが作られる。

VERSION 0.6
FROM golang:1.17-alpine3.14
WORKDIR /go-cleanarchitecture

deps:
  COPY go.mod go.sum .
  RUN apk add --no-cache build-base
  RUN go mod download

build:
  FROM +deps
  COPY . .
  RUN go build -o build/go-cleanarchitecture main.go
  SAVE ARTIFACT build/go-cleanarchitecture AS LOCAL build/go-cleanarchitecture

image:
  COPY +build/go-cleanarchitecture .
  EXPOSE 8080
  ENTRYPOINT ["/go-cleanarchitecture/go-cleanarchitecture"]
  SAVE IMAGE go-cleanarchitecture:latest

開発時のミドルウェア立ち上げまわり

middlewares-up はもともとはdocker-compose.ymlでやっていた処理をearthlyに移植したもの。

LOCALLY コマンドを使うとホストのDocker上でコンテナが実行される。本当はホストではなくEarthlyが起動するコンテナ上ですべて実行したかったが、--net オプション指定でコンテナに接続させるネットワークを同じものにしても、なぜかdocker runを実行するターゲットが異なると動かなかった。というわけで、仕方なくホスト側で実行するようにしている。

# Development

run:
  LOCALLY
  WITH DOCKER --load app:latest=+image
    RUN docker run --net=go-cleanarchitecture-network --net-alias=app \
      -p 8080:8080 --env APP_ENV=production --rm app:latest -http
  END

middlewares-up:
  LOCALLY
  WITH DOCKER \
      --load db:latest=+db \
      --load redis:latest=+pubsub
    RUN docker network create go-cleanarchitecture-network && \
      docker run -d --net=go-cleanarchitecture-network --net-alias=db \
        --name go-cleanarchictecture-db -p 3306:3306 --rm db:latest && \
      docker run -d --net=go-cleanarchitecture-network --net-alias=redis \
        --name go-cleanarchictecture-redis -p 6379:6379 --rm redis:latest
  END

middlewares-down:
  LOCALLY
  WITH DOCKER
    RUN docker stop go-cleanarchictecture-db go-cleanarchictecture-redis && \
      docker network rm go-cleanarchitecture-network
  END

WITH DOCKER コマンドには --compose というdocker-compose.yamlを読み込むオプションも付いているが、0.6.1の段階ではearthlyがdocker-compose.yamlのネットワーク定義のセクションを読んでいないっぽく、ミドルウェアを立ち上げても +run で立ち上げたアプリケーションが繋ぎに行けない様子。今回はいったん諦めてdocker-compose.yamlを削除しearthlyで完結させることで解決した。

ちなみにdbやpubsubなどのミドルウェアの定義はこんな感じ。この辺はシンプルでいい。

# Middlewares

db:
  FROM mysql:5.7
  ENV MYSQL_ROOT_USER=root
  ENV MYSQL_ROOT_PASSWORD=password
  ENV MYSQL_DATABASE=todoapp
  EXPOSE 3306
  SAVE IMAGE db:latest

pubsub:
  FROM redis:6.2.6-alpine3.15
  EXPOSE 6379
  SAVE IMAGE pubsub:latest

E2Eテスト

integration-testも元々はdocker-composeでミドルウェアの立ち上げをするようにしていたところなので、なかなか激しい感じになっている。

上では書き忘れていたが WITH DOCKER の中では RUN コマンドをひとつしか配置できない(!)という制約が現状あり、そのせいで&&を多用せざるを得なくなっている。正直、この辺の書き味はまだまだなんともいえない。

integration-test:
  LOCALLY
  WITH DOCKER \
      --load db:latest=+db \
      --load redis:latest=+pubsub \
      --load migrater:latest=+migrater \
      --load dredd:latest=+dredd \
      --load app:latest=+image
    RUN docker network create test-network && \
      docker run -d --name=db --net=test-network --net-alias=db -p 3306:3306 --rm db:latest && \
      docker run -d --name=redis --net=test-network --net-alias=redis -p 6379:6379 --rm redis:latest && \
      while ! nc 127.0.0.1 3306; do sleep 1 && echo "wait..."; done && sleep 15 && \
      docker run --net=test-network --rm migrater:latest migrate && \
      docker run -d --name=app --net=test-network --net-alias=app -p 8080:8080 --env APP_ENV=production --rm app:latest -http && \
      while ! nc 127.0.0.1 8080; do sleep 1 && echo "wait..."; done && sleep 15 && \
      docker run --net=test-network -w /app --rm dredd:latest && \
      docker stop db redis app && \
      docker network rm test-network
  END

dredd:
  FROM apiaryio/dredd
  COPY . /app
  COPY api-description.apib dredd_hook.js .
  ENTRYPOINT dredd api-description.apib http://app:8080 --hookfiles=dredd_hook.js
  SAVE IMAGE dredd:latest

ほかにもキャッシュの機構だったり、エラーがおきたときにその場で起動中のコンテナにsshできるインタラクティブモードというやつがあったりするが、ここでは省略。公式ドキュメントがめちゃくちゃ整備されているので、それをみるのが早い。

追記: キャッシュの話だけ追加で記事書きました izumisy.work

所感

いいところ

  • DockerとEarthlyさえあればどこでも再現性の高いビルド環境が用意できる
  • EarthlyだけでMakefile/Dockerfile/docker-compose.yamlを代替できる

ビミョい、あるいは難しかったところ

  • ターゲットの依存関係でレイヤキャッシュが効く条件がパッとは分からなかった
    • COPY, FROM, BUILD の3つがあるが、FROM だとキャッシュが効かないことがありどういう条件でキャッシュが効くのかすぐには分からなかった。これは自分のDocker力の問題かもしれない。
  • LOCALLYWITH DOCKER を使うことで受ける暗黙的な制限がいろいろある
    • この辺は使ってみると体験できる。基本は使わない方がいいような気もする。
  • WITH DOCKER--compose オプションが提供するdocker-compose.ymlとの連携が弱い
    • ネットワーク指定だったりボリューム指定だったりがたぶん動かない気がする。そもそもEarthlyで全部やれるんだからdocker-compose.yamlいらないだろ、という話かもしれない。
  • インラインキャッシュ
    • Earthlyのおすすめキャッシュ機構としてインラインキャッシュというやつがあり、これを有効にするとDockerhubなどのコンテナ・レジストリにキャッシュイメージがpushされる。キャッシュがイメージ化されることで、イメージをpullできればあらゆる環境でキャッシュを利用できるというメリットがある。しかし一方で、たとえばDockerhubだと無料プランの人はrate limitがあったりするわけなので、無料で使い倒そうとするとrate limitに引っ掛からないようキャッシュを頻繁に更新したり取りにいったりしないようにする、みたいな工夫が必要になる。
    • 正直、個人的にはファイルキャッシュができると嬉しいがGithub Issueを見た感じあんまり盛り上がってなかった。業務開発ならクラウドプラットフォームで用意されているGCRとかAWS ECRあたりが使えるだろうから、実際にはrate limitとかは些細な問題なのかもしれない。
  • CI環境(CircleCI)ではたぶんインラインキャッシュキャッシュがないと実行時間が長くなる
    • docker-composeを使っていたときは特段キャッシュなどはしていなかった。それでもdocker-composeのほうが1.5倍くらい実行速度は速かった。
    • むしろCI上でインラインキャッシュを使わずEarthlyを使うなんてありえない、という話なのかもしれない。

まとめ

いいところの5倍くらい微妙なところを書いてしまった...

が、まだまだ発展途上なのでこれからの進化に期待。ポテンシャルはめちゃくちゃありそうなので、とにかくおすすめです。自分は結構ハマりそうな予感。