Go で書かれたアプリをマルチプラットフォームイメージにする(再チャレンジ)
はじめに
つい最近「Go で書かれたアプリをマルチプラットフォームイメージにする」と言う記事を書いたのだが、渋川氏[1]に「えーマジ COPY/ADD!?」「キモーイ」「COPY/ADD が許されるのは小学生までだよねー」「キャハハハハハハ」と言われてしまったので(言われてない)[2]、知識のアップデートを行ってより良い Dockerfile にチャレンジしたいと思う。
前回書いた Dockerfile
まず最初に前回書いた Dockerfile を再掲しておく。
FROM golang:1.18-alpine AS build-env
WORKDIR /app
COPY go.mod .
COPY go.sum .
RUN apk add --no-cache upx || \
go version && \
go mod download
COPY . .
ARG TARGETARCH
RUN CGO_ENABLED=0 GOARCH=$TARGETARCH go build -buildvcs=false -trimpath -ldflags '-w -s' -o /go/bin/longcat
RUN [ -e /usr/bin/upx ] && upx /go/bin/longcat || echo
FROM scratch
COPY /go/bin/longcat /go/bin/longcat
ENTRYPOINT ["/go/bin/longcat"]
今回はこれをベースに修正していく。
イマドキは COPY/ADD は使わないらしい
こちらに書かれているように、コンパイルを必要とする言語ではソースファイルに COPY
/ADD
は使わずに RUN --mount=type=bind,source=...,target=...
とするのが今風らしい。
COPY
/ADD
を使うと指定されたファイルをイメージの新たなレイヤにコピーするが、--mount=type=bind
であればコピーせずに直接ファイルを参照できる。直接参照できるのであればコピーは単なるムダだ。
と言うわけで、まずは COPY
を無くす。なお type=bind
は省略可能なので省略した。
FROM golang:1.18-alpine AS build-env
WORKDIR /app
RUN \
apk add --no-cache upx || \
go version && \
go mod download
ARG TARGETARCH
RUN \
CGO_ENABLED=0 GOARCH=$TARGETARCH go build -buildvcs=false -trimpath -ldflags '-w -s' -o /go/bin/longcat
RUN [ -e /usr/bin/upx ] && upx /go/bin/longcat || echo
FROM scratch
COPY /go/bin/longcat /go/bin/longcat
ENTRYPOINT ["/go/bin/longcat"]
なるほど、確かに何となくスッキリしたような気がする[3]。また、コピーするという処理も無くなっているのでビルドが少し速くなったような気もする[4]。
なお、もともとマルチステージビルドなので、これによってムダなコピーが無くなっても最終的なイメージのサイズが減るわけでは無いので注意。サイズが減るのは docker のレイヤキャッシュ、つまりビルドを実行しているマシンのディスクがムダに使われなくなっただけだ[5]。
キャッシュを使うといいらしい
docker でキャッシュと言えばレイヤキャッシュの事だと思っていたが、実はそれとは別に RUN --mount=type=cache,target=...
で使えるキャッシュがあるらしい[6]。で、これを使うと何が嬉しいかと言うと(理系方言)、レイヤキャッシュとは独立したキャッシュになる、と言う事だ。
たとえば、上記の Dockerfile では go mod download
の結果(ダウンロードされたモジュール)がレイヤキャッシュとなって保存され、次の RUN
コマンドはそのレイヤキャッシュを使うことが出来る。
ここで go.mod
や go.sum
が少しだけ変更された場合を考えてみると、以前実行された go mod download
の結果作成されたレイヤキャッシュ自体が無効化されるので(入力ファイルに変更があるとレイヤキャッシュが無効化される)、再度 go mod download
が実行されるが、この際以前の実行でダウンロードされたモジュール達は跡形もなくなっているため、全てのモジュールをダウンロードする羽目になる。
そこで RUN --mount=type=cache,target=...
の出番だ。
FROM golang:1.18-alpine AS build-env
WORKDIR /app
RUN \
apk add --no-cache upx || \
go version && \
go mod download
ARG TARGETARCH
RUN \
CGO_ENABLED=0 GOARCH=$TARGETARCH go build -buildvcs=false -trimpath -ldflags '-w -s' -o /go/bin/longcat
RUN [ -e /usr/bin/upx ] && upx /go/bin/longcat || echo
FROM scratch
COPY /go/bin/longcat /go/bin/longcat
ENTRYPOINT ["/go/bin/longcat"]
見ての通り 2 箇所の RUN
に --mount=type=cache,target=/go/pkg/mod/
を追加した。/go/pkg/mod/
は golang 公式イメージでのモジュールのダウンロード先(つまりモジュールキャッシュ)である。これによって、Go のモジュールキャッシュはレイヤキャッシュとは独立した場所に保存されることになる。
それでも go.mod
が変わった場合に go mod download
自体が再実行されることに変わりはないが(レイヤキャッシュが無効になるので)、--mount=type=cache
にある Go のモジュールキャッシュは無効にはならないので、全てのモジュールがダウンロードされるわけではなく差分のみがダウンロードされる。つまりムダなダウンロードがされなくなるのだ。
これ割といい感じに効くので[7]、是非使ってみて欲しい。
ビルドキャッシュにも使える
先程は Go のモジュールキャッシュを docker のレイヤキャッシュじゃないキャッシュに保存するように変更したが、Go にはビルドキャッシュもあるので、こちらもレイヤキャッシュとは独立したキャッシュに保存した方が効率的だ。
FROM golang:1.18-alpine AS build-env
WORKDIR /app
RUN \
apk add --no-cache upx || \
go version && \
go mod download
ARG TARGETARCH
RUN \
CGO_ENABLED=0 GOARCH=$TARGETARCH go build -buildvcs=false -trimpath -ldflags '-w -s' -o /go/bin/longcat
RUN [ -e /usr/bin/upx ] && upx /go/bin/longcat || echo
FROM scratch
COPY /go/bin/longcat /go/bin/longcat
ENTRYPOINT ["/go/bin/longcat"]
go build
の RUN
に --mount=type=cache,target=/root/.cache/go-build/
を追加した。/root/.cache/go-build/
は golang 公式イメージでのビルドキャッシュの格納場所である。
こうすることによって、ソースが変更になって go build
が再実行される場合でも前回までのビルドキャッシュは無効にならないので、ビルドが高速になる。
これはホントにくっそ効くので[8]、是非使ってみて欲しい。
よくよく見たら apk は別がいんじゃね?
ふと気づいてしまったが、apk
コマンドはソースファイルとは完全に独立しているので go mod download
と同じ RUN
にいる必要は全くなかった。
と言うわけで分離する。
FROM golang:1.18-alpine AS build-env
WORKDIR /app
RUN apk add --no-cache upx || \
go version
RUN \
go mod download
ARG TARGETARCH
RUN \
CGO_ENABLED=0 GOARCH=$TARGETARCH go build -buildvcs=false -trimpath -ldflags '-w -s' -o /go/bin/longcat
RUN [ -e /usr/bin/upx ] && upx /go/bin/longcat || echo
FROM scratch
COPY /go/bin/longcat /go/bin/longcat
ENTRYPOINT ["/go/bin/longcat"]
そうすると気になるのが、apk add
に付いてる --no-cache
である。キャッシュなので、コイツも type=cache
の餌食だ。
FROM golang:1.18-alpine AS build-env
WORKDIR /app
RUN \
apk add upx || \
go version
RUN \
go mod download
ARG TARGETARCH
RUN \
CGO_ENABLED=0 GOARCH=$TARGETARCH go build -buildvcs=false -trimpath -ldflags '-w -s' -o /go/bin/longcat
RUN [ -e /usr/bin/upx ] && upx /go/bin/longcat || echo
FROM scratch
COPY /go/bin/longcat /go/bin/longcat
ENTRYPOINT ["/go/bin/longcat"]
これでビルドする度に毎回インデックスのダウンロードを待つ事もなくなる。パッケージ自体はダウンロードされちゃうのでそこまでの効果は無いが、ちょっとでもムダなダウンロードを省けると言うのは精神衛生上良いことだ。
ちなみに、alpine の場合はインデックスのダウンロードが回避されるだけだが、例えば debian とかであればダウンロードされる deb ファイルもキャッシュに残せたりするので[9]、パッケージのインストールが劇的に速くなる。
でも注意すべき点もあるよ
改めて見てみると RUN --mount
の万能感は半端ない。が、いくつか注意点があった。
type=bind の場合の注意点
type=bind
の場合、デフォルトでは対象のファイル、ディレクトリはリードオンリーになる。ファイルなら通常困ることは無いと思うが、ディレクトリの場合はその配下にファイルを作成できない、と言う事になるので、ビルド内容によっては困ることもあるだろう。
例えば上記の Dockerfile で go build
に -o /go/bin/longcat
オプションを付けないとカレントディレクトリに longcat
を生成しようとして失敗する。
回避方法としては、上記の Dockerfile のように生成物が別ディレクトリに生成されるように指定するか、読み書き可能オプション(rw
)を付けることだ。rw
オプション指定は以下のようになる。
RUN \
CGO_ENABLED=0 GOARCH=$TARGETARCH go build -buildvcs=false -trimpath -ldflags '-w -s'
ただし、rw
オプションの場合そのディレクトリ配下に対する修正(新たに生成されたファイルやディレクトリも含む)は当該 RUN
コマンド終了時に消えてしまう。今回の例では go build
の結果(longcat
バイナリ)は次のコマンドの実行時にはきれいさっぱり消えている。したがって、この場合には go build
に続いて && cp longcat /go/bin/
とかやって消えない場所にコピーする等の対処が必要になるので、指定できるのであれば生成物が別ディレクトリに生成されるようにする方が良いと思う。
type=cache の場合の注意点
type=cache
の場合は type=bind
とは違ってデフォルトでは対象のファイル、ディレクトリは読み書き可能である(リードオンリーにもできる)。まぁ当たり前っぽいな。で、じゃあ安心だ、とか思ってると別のワナにハマる。
何がワナかと言うと、このキャッシュはマウント先のディレクトリ名が同じであれば、同じ docker engine(より正確には同じ builder)で共有される、と言うことだ。これは Dockerfile
が同じものだろうと同じでなかろうと関係ない。
実は上記の例ではそれに対応する記載がある。apk のキャッシュに付けた sharing=locked
だ。これを付けないと同時実行された docker build
によって apk add
がエラーになったりする。apk add
を実行する際にファイルのロックをしているっぽいのだが、これに失敗するからだが、sharing=locked
を付けることでそもそもキャッシュが同時に使用されないようになる(現在使用中のキャッシュを使おうとすると、今使ってるヤツが使い終わるまで待たされる)。
じゃあ何でも sharing=locked
を付ければいいかと言うとそうでもない。上記の例で Go のモジュールキャッシュでは sharing=locked
を付けていないが、これはそもそも Go のモジュールキャッシュが同時に更新されても問題無いように作られているからだ。これに sharing=locked
を付けてしまうとせっかく Go が頑張って同時ビルドしても問題無いように作られてるのに、同時ビルドが走らないようになってしまってかえってビルドが遅くなってしまう可能性があるからだ。
と言うわけで、sharing=locked
を付けるか否かはよくよく見極める必要がある。
よし、sharing=locked
を付けるかどうかさえ気を付ければ良いんだな、と思ってるとまた別のワナにハマる可能性もある。
先程書いたように、キャッシュはマウント先のディレクトリ名が同じであれば共有される。つまり、たまたまディレクトリ名が同じであれば、全く無関係なキャッシュが共有されてしまう可能性がある、と言うことだ。
たとえば、A さんが書いた Dockerfile では /app/cache
配下に自分のアプリケーション用の処理済みデータファイル application.txt
をキャッシュしているが、実は B さんも同じ場所に全く異なるアプリケーション用の処理済みデータファイル application.txt
をキャッシュしたりすると、確実に悲しい事が起きる。
と言うわけで、type=cache
を使うのは世の中一般的に「このディレクトリはこの用途やろ!」とのコンセンサスが得られているものだけに使うのが良さそうではある。(/go/pkg/mod/
とか /root/.cache/go-build/
とかはそう言っていいんじゃないかと思う)
ちなみに、type=cache
には id
なるオプションがあって、実はこの値が同じものを共有する。そして、この値のデフォルト値が target
の値なのでマウント先のディレクトリ名が同じだと共有されるのだ。したがって、ここに「ほかのヤツは絶対使わんやろ」的な値(例えば UUID とかか?)を明示的に指定すればそのような事故が起こる可能性限りなく低くはなる。低くはなるが、何もそこまでしなくても…と思わなくもない。
おわりに
と言うわけで、以前に書いた Dockerfile を渋川氏に「キモーイ」と言われないように修正した(しつこいようだが言われてない)。
それでは、よい Go & Dockerfile ライフを。
Discussion