🐔

Go で書かれたアプリをマルチプラットフォームイメージにする(再チャレンジ)

2024/09/10に公開

はじめに

つい最近「Go で書かれたアプリをマルチプラットフォームイメージにする」と言う記事を書いたのだが、渋川氏[1]に「えーマジ COPY/ADD!?」「キモーイ」「COPY/ADD が許されるのは小学生までだよねー」「キャハハハハハハ」と言われてしまったので(言われてない)[2]、知識のアップデートを行ってより良い Dockerfile にチャレンジしたいと思う。

前回書いた Dockerfile

まず最初に前回書いた Dockerfile を再掲しておく。

FROM --platform=$BUILDPLATFORM 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 --from=build-env /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 --platform=$BUILDPLATFORM golang:1.18-alpine AS build-env

WORKDIR /app
RUN --mount=source=go.mod,target=go.mod \
    --mount=source=go.sum,target=go.sum \
    apk add --no-cache upx || \
    go version && \
    go mod download
ARG TARGETARCH
RUN --mount=target=. \
    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 --from=build-env /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.modgo.sum が少しだけ変更された場合を考えてみると、以前実行された go mod download の結果作成されたレイヤキャッシュ自体が無効化されるので(入力ファイルに変更があるとレイヤキャッシュが無効化される)、再度 go mod download が実行されるが、この際以前の実行でダウンロードされたモジュール達は跡形もなくなっているため、全てのモジュールをダウンロードする羽目になる。

そこで RUN --mount=type=cache,target=... の出番だ。

FROM --platform=$BUILDPLATFORM golang:1.18-alpine AS build-env

WORKDIR /app
RUN --mount=type=cache,target=/go/pkg/mod/ \
    --mount=source=go.mod,target=go.mod \
    --mount=source=go.sum,target=go.sum \
    apk add --no-cache upx || \
    go version && \
    go mod download
ARG TARGETARCH
RUN --mount=type=cache,target=/go/pkg/mod/ \
    --mount=target=. \
    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 --from=build-env /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 --platform=$BUILDPLATFORM golang:1.18-alpine AS build-env

WORKDIR /app
RUN --mount=type=cache,target=/go/pkg/mod/ \
    --mount=source=go.mod,target=go.mod \
    --mount=source=go.sum,target=go.sum \
    apk add --no-cache upx || \
    go version && \
    go mod download
ARG TARGETARCH
RUN --mount=type=cache,target=/go/pkg/mod/ \
    --mount=type=cache,target=/root/.cache/go-build/ \
    --mount=target=. \
    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 --from=build-env /go/bin/longcat /go/bin/longcat
ENTRYPOINT ["/go/bin/longcat"]

go buildRUN--mount=type=cache,target=/root/.cache/go-build/ を追加した。/root/.cache/go-build/ は golang 公式イメージでのビルドキャッシュの格納場所である。

こうすることによって、ソースが変更になって go build が再実行される場合でも前回までのビルドキャッシュは無効にならないので、ビルドが高速になる。

これはホントにくっそ効くので[8]、是非使ってみて欲しい。

よくよく見たら apk は別がいんじゃね?

ふと気づいてしまったが、apk コマンドはソースファイルとは完全に独立しているので go mod download と同じ RUN にいる必要は全くなかった。

と言うわけで分離する。

FROM --platform=$BUILDPLATFORM golang:1.18-alpine AS build-env

WORKDIR /app
RUN apk add --no-cache upx || \
    go version
RUN --mount=type=cache,target=/go/pkg/mod/ \
    --mount=source=go.mod,target=go.mod \
    --mount=source=go.sum,target=go.sum \
    go mod download
ARG TARGETARCH
RUN --mount=type=cache,target=/go/pkg/mod/ \
    --mount=type=cache,target=/root/.cache/go-build/ \
    --mount=target=. \
    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 --from=build-env /go/bin/longcat /go/bin/longcat
ENTRYPOINT ["/go/bin/longcat"]

そうすると気になるのが、apk add に付いてる --no-cache である。キャッシュなので、コイツも type=cache の餌食だ。

FROM --platform=$BUILDPLATFORM golang:1.18-alpine AS build-env

WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apk/,sharing=locked \
    apk add upx || \
    go version
RUN --mount=type=cache,target=/go/pkg/mod/ \
    --mount=source=go.mod,target=go.mod \
    --mount=source=go.sum,target=go.sum \
    go mod download
ARG TARGETARCH
RUN --mount=type=cache,target=/go/pkg/mod/ \
    --mount=type=cache,target=/root/.cache/go-build/ \
    --mount=target=. \
    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 --from=build-env /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 --mount=type=cache,target=/go/pkg/mod/ \
    --mount=type=cache,target=/root/.cache/go-build/ \
    --mount=target=.,rw \
    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 ライフを。

脚注
  1. フューチャー技術ブログでは「澁川」表記になってるんだけど、Twitter やブログが「渋川」表記になってるのでそっちに合わせた ↩︎

  2. 2024年版のDockerfileの考え方&書き方」を参照 ↩︎

  3. 気のせいかもしれない ↩︎

  4. 気のせいかもしれない ↩︎

  5. それだけでも修正する価値はあるかもしれない ↩︎

  6. このキャッシュを何と呼べばいいのか分からない。教えてエロい人 ↩︎

  7. 特に依存モジュールがちょいちょい変わってるような場合にマジで効く ↩︎

  8. 開発中に docker build 叩きまくってるような場合は異様なほど効く ↩︎

  9. ここの Apt のところを参照 ↩︎

Discussion