ローカルではうまくいってたのにデプロイしたらうまく動かなくなったみたいなことありがち。
原因を探っていったらdockerをローカルでビルドした状況とステージング環境でビルドした状況が異なっていたためだった。
普段意識しなかったんだけどデプロイ時はgo buildした後の実行ファイルのみをコンテナに渡すようにしていた。
軽量化のために結構やる手法みたい
結構ハマったのと、この過程でマルチステージビルドって機能があるのを知ってこんな方法あるんだーと勉強になったのでメモ。
背景
開発環境で動いてたものをデプロイしたら動かなくなったのでその原因を調査していたのが始まり。
何をしていたかというとバックエンドからFirebaseの機能でアプリへプッシュ通知を行っていた。
詳細な使い方は省略するが、環境変数でFirebaseのクライアント情報を含むファイルのパスを指定する必要があった。
SDKを使うと環境変数からそのファイルを読み込んでクライアントを作成してくれるっぽい。
なのでdocker-compose.ymlにこんな風に書いてた(一部のみ記載)
services:
api:
build:
context: ./api
environment:
- GOOGLE_APPLICATION_CREDENTIALS=/go/api/設定ファイルへのパス/service-account.json
volumes:
- ./api:/go/api
dockerfileでWORKDIRを/go/apiにしているので、volumesでapi配下のファイルは全て/go/api配下にマウントしている
そのため、環境変数に記載するファイルのパスは/go/apiにしておくことがポイント
これはローカル環境ではうまく動いてくれたのだが、デプロイすると動かなくなった。
デプロイ先はFargateだったのでタスク定義で環境変数を定義する時に以下のようにしていることは確認した。
{
"name": "GOOGLE_APPLICATION_CREDENTIALS",
"value": "/go/api/設定ファイルへのパス/service-account.json"
}
実際にデプロイした環境のログを見てもルートパスは同じ/go/apiになってるのになんでだろと思ったのがそもそもの発端。
なぜステージング環境ではうまく動かなかったのか
結論、ステージング環境でビルドした時にはgo buildした後にできた実行ファイルのみをコンテナにマウントさせていたからだった。
つまりローカルではapi配下の全てのファイルをコンテ内にマウントしていたため環境変数で指定したパスにファイルが存在していたのに対し、ステージング環境ではgo buildしてできた単一の実行ファイルのみがコンテナにマウントされていた
したがって環境変数で指定した/go/api/設定ファイルへのパス
なんてものは存在していなかった(ナンテコッタイ)
当時は知らなかったけどこういうビルドした後の実行ファイルのみコンテナに渡すみたいのは軽量化のためによくやるみたい。コンパイラ言語!って感じした。
ってことでコンテナにマウントする時に実行ファイル以外に設定ファイルも一緒にマウントしてあげれば解決する。
エラー解決としてはこれでokなんだけど、その際にdockerのマルチステージビルドってのを知ったのでこれに関して簡単にまとめておく
dockerのマルチステージビルドとは?
dockerfileからビルドする際にそのイメージサイズをできるだけ小さくしようとするのはよくあること。
その文脈で、開発環境ではアプリに必要なものをすべて含めるようなDockerfileを用意し、本番環境では必要最低限のもののみを含めるようにしたDockerfileを用意するみたいなことをしたりするんだけど、2つのファイルを用意してそれらをわちゃわちゃするのは煩雑だし、2つのファイルをメンテしていくのは面倒。
これを1つにまとめられるのがマルチステージビルド
以下のように書く
FROM golang:1.14.7-alpine3.12 as develop
WORKDIR /go/api
RUN なんやかんや処理
FROM golang:1.14.7-alpine3.12 as builder
WORKDIR /go/api
COPY go.mod go.sum ./
RUN go mod download
RUN なんやかんや処理
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /go/bin/api
FROM scratch as prod
COPY --from=builder /go/bin/api /go/bin/api
...
1つのDockerfile内にFROM句が3つあってasでエイリアスを設定している
これがそれぞれの環境に該当する
それぞれのステージでは直前のステージで作り出された生成内容を、新たなステージにコピーすることができる。
下記のハイライトした部分は直前のbuilderでの成果物をCOPY --from=builder
としてそのままコピーしている。
FROM golang:1.14.7-alpine3.12 as develop
WORKDIR /go/api
RUN なんやかんや処理
FROM golang:1.14.7-alpine3.12 as builder
WORKDIR /go/api
COPY go.mod go.sum ./
RUN go mod download
RUN なんやかんや処理
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /go/bin/api
FROM scratch as prod
COPY --from=builder /go/bin/api /go/bin/api
...
ただコピーしてるだけなのにこれの何がいいかというとbuilderではgolangのalpineからイメージを作っているのに対してprodではscratchからイメージを作っている。
scratchはshさえ入っていないわたあめ並みの超軽量イメージなので、そこにビルド結果の実行ファイルをコピーすれば様々な中間生成物やGoのSDKさえ存在しないので全体的にめちゃくちゃ軽量になる。
このようなことをするにはFROMから分離する必要があるので、2つのファイルに分けてなんやかんやするか今回みたいにマルチビルドステージを使うことになる。
1つのファイルで管理できるし使い方も簡単だしマルチステージビルド便利だよねって話。
ちなみにbuildする時は必ず最後のprodまでbuildしなきゃいけないってわけではなくて、builderまででもok
それはdocker buildする時に指定できる
最終的な成果物
話を元に戻して、最終的にデプロイした環境で環境変数からパスを読み込んで動くようにするには以下のようにすればうまくいった。
まずDockerfileで環境変数で読み込ませたいファイルをコンテナの中にコピーする
(bin配下が適切かは疑問)
FROM golang:1.14.7-alpine3.12 as builder
...
FROM scratch as prod
COPY --from=builder /go/bin/api /go/bin/api
COPY --from=builder /go/api/設定ファイルへのパス/service-account.json /go/bin/
...
んでFargateのタスク定義で環境変数を以下のように上記でコピーした場所になるように修正する。
{
"name": "GOOGLE_APPLICATION_CREDENTIALS",
"value": "/go/bin/service-account.json"
}
あとはCDをCodePipelineにしていたのでbuildspec.yml内でdocker buildする時に以下のように--target prod
として、prodまでbuildするように設定しておけばok
docker build --target prod -t $IMAGE_REPO_NAME:$IMAGE_TAG ./api/.
おわりに
Dockerを使う時にできるだけ軽量なイメージでってのは知ってたけど、scratchとかでshさえないくらいまで軽量化させる技術は知らなかったので勉強になった
コンパイルした実行ファイルだけコンテナにコピーってのもよくやる手法らしいので覚えておきたい
環境ごとの設定を1つのファイルでできるのは便利
参考
https://matsuand.github.io/docs.docker.jp.onthefly/develop/develop-images/multistage-build/
https://dev.classmethod.jp/articles/exploration-to-docker-scratch/
コメント