Go で書かれたアプリをマルチプラットフォームイメージにする
はじめに
Go を使って書いたアプリをコンテナイメージにすると言うことは割と良くあるのではないかと思う。また、コンテナイメージを作成する場合にそれをマルチプラットフォームイメージにすると言う事も割と良くあるのではないかと思う。
これらが良くある、と言うのはみんな思っているようで、実は公式ドキュメントにもやり方がちょっとだけ書いてある。書いてはあるんだが、公式ドキュメントの記述は golang のベースイメージを使ってるにもかかわらず go によるビルドそのものについては書かれていないし、そもそも英語で書かれているので読む気がかなり削がれる[1]。
と言う訳で、Go で書かれたアプリをマルチプラットフォームイメージにする方法について記事にしようと思う。
longcat はいいぞ!
皆さんもご存知だとは思うが、世の中には longcat
と言う mattn 氏[2]によって Go で書かれた大層有用なツールがある。
今回はこれを教材として利用させて頂こうと思う。念のために書いておくと、決して longcat の公式リリースに何か問題がある訳では無く、単に Go で書かれた手軽なサイズのソースが欲しかっただけだ。
何はともあれまずはリポジトリを clone しよう。
$ git clone https://github.com/mattn/longcat.git
Cloning into 'longcat'...
remote: Enumerating objects: 514, done.
remote: Counting objects: 100% (102/102), done.
remote: Compressing objects: 100% (79/79), done.
remote: Total 514 (delta 45), reused 65 (delta 22), pack-reused 412
Receiving objects: 100% (514/514), 1.95 MiB | 3.25 MiB/s, done.
Resolving deltas: 100% (249/249), done.
$ cd longcat
さて、リポジトリにはちゃんと 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 . .
RUN CGO_ENABLED=0 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"]
Dockerfile が用意されているのでイメージをビルドするのは簡単だ。
$ docker build -t longcat .
これで longcat のイメージが出来た。
そうだ、マルチプラットフォームイメージを作ろう
さて、先程の方法ではビルド環境のみに対応したイメージしかできない。イマドキであれば最低でも amd64 と arm64 のマルチプラットフォームイメージにすることが多いに違いない。
最近(?)は buildx プラグインのおかげでマルチプラットフォームイメージを手軽に作れるようになったので、buildx を使ってマルチプラットフォームイメージをビルドしよう。
まずは amd64/arm64 両対応のビルダインスタンスを作成する。
$ docker buildx create --name longcat --platform amd64,arm64 --bootstrap --use
[+] Building 3.8s (1/1) FINISHED
=> [internal] booting buildkit 3.8s
=> => pulling image moby/buildkit:buildx-stable-1 3.1s
=> => creating container buildx_buildkit_longcat0 0.7s
longcat
で、コイツを使ってビルドすればいいのだが、実はこのままビルドすると大抵の人はちょっとした悲しみを味わうことになる。どう悲しいかを示すために、とりあえずやってみる。
$ docker buildx build -t ghcr.io/kariya-mitsuru/longcat . --platform amd64,arm64 --push
[+] Building 440.1s (26/26) FINISHED docker-container:longcat
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 441B 0.0s
=> [linux/arm64 internal] load metadata for docker.io/library/golang:1.18-alpine 2.7s
=> [linux/amd64 internal] load metadata for docker.io/library/golang:1.18-alpine 2.7s
=> [auth] library/golang:pull token for registry-1.docker.io 0.0s
=> [internal] load .dockerignore 0.1s
=> => transferring context: 2B 0.0s
=> [internal] load build context 0.2s
=> => transferring context: 2.84MB 0.1s
=> [linux/amd64 build-env 1/8] FROM docker.io/library/golang:1.18-alpine@sha256:77f25981bd57e60a510165f3be89c901aec90453fd0f1c5a45691f6cb1528807 235.0s
...中略...
=> [linux/arm64 build-env 1/8] FROM docker.io/library/golang:1.18-alpine@sha256:77f25981bd57e60a510165f3be89c901aec90453fd0f1c5a45691f6cb1528807 204.6s
...中略...
=> [linux/arm64 build-env 2/8] WORKDIR /app 0.4s
=> [linux/arm64 build-env 3/8] COPY go.mod . 0.1s
=> [linux/arm64 build-env 4/8] COPY go.sum . 0.1s
=> [linux/arm64 build-env 5/8] RUN apk add --no-cache upx || go version && go mod download 62.0s
=> [linux/amd64 build-env 2/8] WORKDIR /app 0.5s
=> [linux/amd64 build-env 3/8] COPY go.mod . 0.1s
=> [linux/amd64 build-env 4/8] COPY go.sum . 0.1s
=> [linux/amd64 build-env 5/8] RUN apk add --no-cache upx || go version && go mod download 47.7s
=> [linux/arm64 build-env 6/8] COPY . . 0.1s
=> [linux/arm64 build-env 7/8] RUN CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags '-w -s' -o /go/bin/longcat 158.9s
=> [linux/amd64 build-env 6/8] COPY . . 0.1s
=> [linux/amd64 build-env 7/8] RUN CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags '-w -s' -o /go/bin/longcat 18.3s
=> [linux/amd64 build-env 8/8] RUN [ -e /usr/bin/upx ] && upx /go/bin/longcat || echo 1.7s
=> [linux/amd64 stage-1 1/1] COPY --from=build-env /go/bin/longcat /go/bin/longcat 0.1s
=> [linux/arm64 build-env 8/8] RUN [ -e /usr/bin/upx ] && upx /go/bin/longcat || echo 3.2s
=> [linux/arm64 stage-1 1/1] COPY --from=build-env /go/bin/longcat /go/bin/longcat 0.0s
=> exporting to image 7.7s
...中略...
=> [auth] kariya-mitsuru/longcat:pull,push token for ghcr.io 0.0s
ちょっと見づらいのだが許して欲しい。
しかし、よく見て頂くと、ビルドにめっさ時間がかかっている(440.1 秒)のが分かるのではないだろうか。
どうしてそんなにノロいのか(童謡風
さて、どうしてそんなにノロいのだろうか。
答えはビルドログを見ればだいたい分かる。
- ビルド用ベースイメージのダウンロードがくっそ遅い。
docker.io/library/golang:1.18-alpine
の linux/arm64 版のダウンロードに 235.0 秒、linux/amd64 版 のダウンロードに 204.6 秒かかっている。と言っても、これらは並行に実行されているので実際にかかっている時間は長い方の 235.0 秒程度だ。これに時間がかかっているのはもちろん我が家のネットワーク環境が貧弱貧弱ゥなせいである。 - amd64 と arm64 の両方のイメージを pull している。
上記にも書いたが、docker.io/library/golang:1.18-alpine
は linux/arm64 版と linux/amd64 版の両方がダウンロードされている。並行実行されているのでダウンロードにかかった時間は長い方の 235.0 秒程度とは言ったが、そもそももし片方だけしかダウンロードしなかった場合にはそこまでかからないはずだ。 - arm64 用のアプリビルドを arm64 でやってる。
最後に、linux/arm64 でのgo build
がくっそ遅い。158.9 秒もかかっている。我が家のマシンは linux/amd64 環境なので、linux/arm64 でのgo build
は qemu のエミュレーション環境で動いてしまっているために、異様に時間がかかってしまっているのだ。
これらのうち、1 についてはあなたの環境ではもっとずっと速いかもしれないし、そもそも Dockerfile をどうにかすることによって我が家のネットワーク環境が爆速になる訳でもないので、誠に残念ではあるが今回は無視する。
Go の強みを生かす
それでは、ここから先程の遅い原因 2 と 3 について対処しよう。
オレが考える Go の素晴らしいところの 1 つは、クロスビルドがアホ程容易なことだ。つまり、我が家の linux/amd64 環境でも直接 linux/arm64 のバイナリを生成することができるし、もしあなたが M[1-3] Mac[3] 使いであれば、その環境の docker で直接 linux/amd64 のバイナリをビルドすることができる。
しかし、先程のマルチプラットフォームビルト方法ではその Go の素晴らしい特徴を生かせていないのだ。
と言う訳で、早速そこを修正する。
FROM golang:1.18-alpine AS build-env
ARG TARGETARCH
WORKDIR /app
COPY go.mod .
COPY go.sum .
RUN apk add --no-cache upx || \
go version && \
go mod download
COPY . .
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 箇所。
- 最初の
FROM
に--platform=$BUILDPLATFORM
を追加。
この指定によって、ビルド対象が amd64 だろうが arm64 だろうがこのビルドステージを実行するのはビルド環境から見たネイティブなアーキテクチャ(例えば我が家では amd64)になる。 - 最初の
FROM
の直後にARG TARGETARCH
を追加。
上記 1 でビルド対象に関わらずネイティブで動くようになったが、ビルド対象のアーキテクチャが分からないことにはクロスビルドが出来ないので、ビルド対象のアーキテクチャが格納されたTARGETARCH
と言う変数を受け取るように宣言。 -
go build
を実行する際の環境変数にGOARCH=$TARGETARCH
を追加。
上記 2 で受け取ったビルド対象のアーキテクチャ名をGOARCH
環境変数に設定することで、go build
をクロスビルドにする。
この Dockerfile でビルドを実行するとこんな感じになる。
$ docker buildx build -t ghcr.io/kariya-mitsuru/longcat . --platform amd64,arm64 --push
[+] Building 222.6s (21/21) FINISHED docker-container:longcat
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 501B 0.0s
=> [linux/amd64 internal] load metadata for docker.io/library/golang:1.18-alpine 2.8s
=> [auth] library/golang:pull token for registry-1.docker.io 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load build context 0.1s
=> => transferring context: 2.84MB 0.0s
=> [linux/amd64 build-env 1/8] FROM docker.io/library/golang:1.18-alpine@sha256:77f25981bd57e60a510165f3be89c901aec90453fd0f1c5a45691f6cb1528807 114.1s
...中略...
=> [linux/amd64->arm64 build-env 2/8] WORKDIR /app 0.4s
=> [linux/amd64->arm64 build-env 3/8] COPY go.mod . 0.1s
=> [linux/amd64->arm64 build-env 4/8] COPY go.sum . 0.1s
=> [linux/amd64->arm64 build-env 5/8] RUN apk add --no-cache upx || go version && go mod download 79.7s
=> [linux/amd64 build-env 5/8] RUN apk add --no-cache upx || go version && go mod download 80.9s
=> [linux/amd64->arm64 build-env 6/8] COPY . . 0.1s
=> [linux/amd64->arm64 build-env 7/8] RUN CGO_ENABLED=0 GOARCH=arm64 go build -buildvcs=false -trimpath -ldflags '-w -s' -o /go/bin/longcat 13.4s
=> [linux/amd64 build-env 6/8] COPY . . 0.1s
=> [linux/amd64 build-env 7/8] RUN CGO_ENABLED=0 GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags '-w -s' -o /go/bin/longcat 13.9s
=> [linux/amd64->arm64 build-env 8/8] RUN [ -e /usr/bin/upx ] && upx /go/bin/longcat || echo 2.1s
=> [linux/amd64 build-env 8/8] RUN [ -e /usr/bin/upx ] && upx /go/bin/longcat || echo 1.6s
=> [linux/arm64 stage-1 1/1] COPY --from=build-env /go/bin/longcat /go/bin/longcat 0.1s
=> [linux/amd64 stage-1 1/1] COPY --from=build-env /go/bin/longcat /go/bin/longcat 0.1s
=> exporting to image 8.1s
...中略...
=> [auth] kariya-mitsuru/longcat:pull,push token for ghcr.io 0.0s
相変わらず見づらくて申し訳ないが、よく見ればビルド時間が大幅に短縮されていることが分かるだろう。440.1 秒 ⇒ 222.6 秒なので、おおよそ半分だ。
細かく見ると、大幅に短縮されたのは以下の 2 点による。
- ビルド用ベースイメージが amd64 版しかダウンロードされていない。
ビルド環境がネイティブ(我が家の場合 amd64)に統一されたため、arm64 版のダウンロードはされなくなった。このため、予想通り amd64 版のダウンロード時間も短縮され、114.1 秒しかかからなくなった。 - arm64 版のアプリビルドが amd64 で行われている。
Go のクロスビルド機能を使用して amd64 上で arm64 版をビルドすることで、qemu による無駄な(?)エミュレーションが走らず、13.4s しかかからなくなった。
どうだろうか?だいぶいい感じになったのではないだろうか?
大事なことなので 2 回やりました
いい感じにはなったものの、まだちょっと気になるところがある。
先程のログを見てみると、上位 3 傑は以下の通りだ。
-
docker.io/library/golang:1.18-alpine
のダウンロード:114.1 秒 - upx のインストールと Go の依存モジュールのダウンロード(amd64 版):80.9 秒
- upx のインストールと Go の依存モジュールのダウンロード(arm64 版):79.7 秒
このうち 1 は先程も触れたとおり我が家のネットワーク環境が弱々なのでいかんともしがたい。
ここで注目すべきは 2 と 3 だ。
2 と 3 は同時並行で実行されているので実質的には遅い方の 80.9 秒程度で終わってはいるとは言え、amd64 版と arm64 版で同じ事をやっている。だが待って欲しい。これらの処理は amd64 版と arm64 版で違いがあるのだろうか?いや、無い。(反語)
良く考えて頂ければ分かると思うが、先程の対処でビルド環境はネイティブなアーキテクチャになっているので、upx のインストールと言うのは同じものをインストールしているはずだし、Go の依存モジュールに至ってはアーキテクチャが何であれダウンロードされるモノは変わらないはずである。
ではなぜ同じことを 2 回やっているのだろうか?大事な事だから 2 回やっているのだろうか?
変数は使う直前に定義しろ
突然だがプログラミングにおいて、変数のスコープはできるだけ狭くせよ、と言う話を聞いたことがある人は多いだろう。グローバル変数はできるだけ使うな、と言うのはこれの一番極端な例である。
何でそんな話をしたかと言うと、今回の例も同じだからだ。
先に正解を出してしまおう。
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"]
どこが変わったかお分かりだろうか?
そう、ARG TARGETARCH
の位置が変わっただけだ。
そして、この Dockerfile を使ってビルドするとこうなる。
$ docker buildx build -t ghcr.io/kariya-mitsuru/longcat . --platform amd64,arm64 --push
[+] Building 153.0s (19/19) FINISHED docker-container:longcat
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 501B 0.0s
=> [linux/amd64 internal] load metadata for docker.io/library/golang:1.18-alpine 4.2s
=> [auth] library/golang:pull token for registry-1.docker.io 0.0s
=> [internal] load .dockerignore 0.1s
=> => transferring context: 2B 0.0s
=> [linux/amd64 build-env 1/8] FROM docker.io/library/golang:1.18-alpine@sha256:77f25981bd57e60a510165f3be89c901aec90453fd0f1c5a45691f6cb1528807 92.4s
...中略...
=> [internal] load build context 0.3s
=> => transferring context: 5.18MB 0.1s
=> [linux/amd64 build-env 2/8] WORKDIR /app 0.4s
=> [linux/amd64 build-env 3/8] COPY go.mod . 0.1s
=> [linux/amd64 build-env 4/8] COPY go.sum . 0.1s
=> [linux/amd64 build-env 5/8] RUN apk add --no-cache upx || go version && go mod download 26.6s
=> [linux/amd64 build-env 6/8] COPY . . 0.2s
=> [linux/amd64 build-env 7/8] RUN CGO_ENABLED=0 GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags '-w -s' -o /go/bin/longcat 18.5s
=> [linux/amd64->arm64 build-env 7/8] RUN CGO_ENABLED=0 GOARCH=arm64 go build -buildvcs=false -trimpath -ldflags '-w -s' -o /go/bin/longcat 18.3s
=> [linux/amd64->arm64 build-env 8/8] RUN [ -e /usr/bin/upx ] && upx /go/bin/longcat || echo 1.6s
=> [linux/amd64 build-env 8/8] RUN [ -e /usr/bin/upx ] && upx /go/bin/longcat || echo 1.7s
=> [linux/arm64 stage-1 1/1] COPY --from=build-env /go/bin/longcat /go/bin/longcat 0.1s
=> [linux/amd64 stage-1 1/1] COPY --from=build-env /go/bin/longcat /go/bin/longcat 0.0s
=> exporting to image 8.4s
...中略...
=> [auth] kariya-mitsuru/longcat:pull,push token for ghcr.io 0.0s
相変わらず見づらくて恐縮だが、ビルドが 153.0 秒で終わっていることが分かるだろう。
そして特筆すべきは先程の 2 回やってる処理が 1 回になっていると言うことだ。なぜ 1 回になったのだろうか?大事じゃなくなったのだろうか?
ここまで言えばもう予想が付いているとは思うが、1 回になった理由は TARGETARCH
変数を定義する場所を go build
の直前に変えたからだ。
Dockerfile では変数の値が異なるとそれぞれの命令は異なる処理とみなされてしまう。TARGETARCH
変数の値はビルド対象によって amd64
だったり arm64
だったりするので、本来アーキテクチャに左右されない処理である「upx のインストールと Go の依存モジュールのダウンロード」もビルド対象毎に実行されてしまっていたのだ。
しかし、変数の影響を受けるのはその変数が定義された後だけなので、ARG TARGETARCH
をホントに影響を受ける go build
の直前に移動することで「upx のインストールと Go の依存モジュールのダウンロード」が TARGETARCH
変数の影響を受けないことを Docker[4] が検知して、2 回やるのをやめたのだ。
つまり、変数は使う直前に定義するのが吉だ。
というわけで、最初に比べて(だいぶ誤差はあるものの)おおよそ 1/3 までビルド時間を短縮できた。
おまけ
元々の Dockerfile にはマルチプラットフォームイメージか否かにかかわらず、Go のアプリをコンテナイメージにする際に有用な仕掛けが 2 つ施されている。
これらも縁起ものなので一応説明を付けておく。
-
go.mod
とgo.sum
だけを先にコピーしてgo mod download
を実行している。
ソース本体に比べて依存モジュールは変更の可能性が低いので、こうすることでビルドキャッシュが効きやすくなる。
また、こうしておくと「変数は使う直前に定義しろ」でやったようにgo mod download
をTARGETARCH
変数の影響範囲外に置くことが出来るようになるので、マルチプラットフォームイメージ作成時にも有利である。 -
go build
の際に環境変数CGO_ENABLED=0
を指定している。
CGO_ENABLED=0
を指定することで出来上がったバイナリが libc の影響を受けなくなるため、最終イメージのベースイメージを scratch や distroless にすることが出来るので、最終イメージのサイズが圧倒的に小さくなる。(実際 longcat の公式イメージはくっそ小さい)
なお、upx
とか -trimpath -ldflags '-w -s'
フラグとかコンテナと関係ないところでも良さそうな事をしているが、オレ自身説明できるほど良く分かってないので説明は割愛させて頂く。
おわりに
と言う訳で、Go で書かれたアプリをマルチプラットフォームイメージにする際に悲嘆に暮れないためにやっておいた方がいいことを説明した。
それでは、よい Go & Dockerfile ライフを。
Discussion