【マルチステージビルド】Go 言語で開発したプロダクトのイメージサイズを小さくする
本記事の目標
こんにちは、FarStep です。
今回は、Docker の Multi-stage build を使って、Go 言語で開発したプロダクトのイメージサイズを小さくする というテーマを扱います。
Multi-stage build とは
Multi-stage build とは何でしょうか。
Multi には、「多くの」・「多重の」・「複数の」といった意味があります。
よって、Multi-stage build とは「複数のステージを用いたビルド」となります。
では、複数のステージを用いる とはどういうことでしょうか。
通常 Docker イメージには、アプリケーションおよび実行に必要なものの他に、イメージのビルドに関わるライブラリ等も含まれています。しかし、本番環境では アプリケーションおよびその実行に必要なもののみ をビルドしたいですよね。
そこで複数のステージを用いると、この悩みが解消されます。
ここで、ステージを二つ用意するとします。
一つ目のステージでは、アプリケーションのビルドを行い Docker イメージを作成します。
二つ目のステージでは、一つ目のステージで作成したイメージの中から必要なものだけをコピーしてきます。
このようにステージを二つ用意することで、最終的な Docker イメージには必要なものだけが含まれるようになるのです。
その結果、イメージサイズが小さくなり、本番環境の運用のパフォーマンスが向上します。
Go 言語を用いた簡単なプロダクトの紹介
今回 Go 言語を用いた簡単なプロダクトとして、REST API を構築したアプリケーションを採用します。プロジェクト全体のコードは、下記をご覧ください。
まずは、レポジトリを clone してきます。
$ git clone git@github.com:NaokiYazawa/multi-stage-build.git
$ cd multi-stage-build
次に .env を作成して、PostgreSQL の環境変数を定義しましょう。
$ touch .env
POSTGRES_USER=postgres
POSTGRES_PASSWORD=secret
POSTGRES_DB=mydb
POSTGRES_HOST=postgres
DB_PORT=5432
それでは、Docker Compose を使って、アプリケーションを立ち上げましょう。
$ docker-compose up
下記のように、サーバーが立ち上がればOKです。
api |
api | ____ __
api | / __/___/ / ___
api | / _// __/ _ \/ _ \
api | /___/\__/_//_/\___/ v3.3.10-dev
api | High performance, minimalist Go web framework
api | https://echo.labstack.com
api | ____________________________________O/_______
api | O\
api | ⇨ http server started on [::]:8080
早速、APIを叩いてみましょう。
Makefile に、curl コマンドを記載しておきました。
$ make create_user
# curl -X POST "http://localhost:8080/users" -H "accept: application/json" -H "Content-Type: application/json" -d "{\"name\":\"Jack\"}"
# {"id":1,"name":"Jack"}
$ make get_users
# curl -X GET "http://localhost:8080/users" -H "accept: application/json"
# [{"id":1,"name":"Jack"}]
$ make get_user
# curl -X GET "http://localhost:8080/users/1" -H "accept: application/json"
# {"id":1,"name":"Jack"}
$ make update_user
# curl -X PUT "http://localhost:8080/users/1" -H "accept: application/json" -H "Content-Type: application/json" -d "{\"name\":\"updated-Jack\"}"
# {"id":1,"name":"updated-Jack"}
$ make delete_user
# curl -X DELETE "http://localhost:8080/users/1" -H "accept: application/json" -H "Content-Type: application/json
上記のコマンドを打って、正常にレスポンスが帰ってくるのを確認しておきましょう。
アプリケーションが正常に動作したところで、次章では、Multi-stage build について解説していきます。
Go 言語における Multi-stage build について
本章では、先ほどのアプリケーションの立ち上げで使った Multi-stage build について説明していきます。
Dockerfile の解説
それでは、clone してきたアプリケーションの Dockerfile を見てみましょう。
解説をコメントとして入れたので、一行一行追ってみてください。
####################### Build stage #######################
# golang:<version>-alpine は、Alpine Linux プロジェクトをベースにしている。
# イメージサイズを最小にするため、git、gcc、bash などは、Alpine-based のイメージには含まれていない。
FROM golang:1.16-alpine3.13 AS builder
# 作業ディレクトリの定義をする。今回は、app ディレクトリとした。
WORKDIR /app
# go.mod と go.sum を app ディレクトリにコピー
COPY go.mod go.sum ./
# 指定されたモジュールをダウンロードする。
RUN go mod download
# ルートディレクトリの中身を app フォルダにコピーする
COPY . .
# 実行ファイルの作成
# -o はアウトプットの名前を指定。
# ビルドするファイル名を指定(今回は main.go)。
RUN go build -o main /app/main.go
####################### Run stage #######################
# Goで作成したバイナリは Alpine Linux 上で動く。
# alpineLinux とは軽量でセキュアな Linux であり、とにかく軽量。
FROM alpine:3.13
# 作業ディレクトリの定義
WORKDIR /app
# Build stage からビルドされた main だけを Run stage にコピーする。(重要)
COPY /app/main .
# ローカルの .env と .wait-for.sh をコンテナ側の app フォルダにコピーする
COPY .env .
COPY wait-for.sh .
# wait-for.sh の権限を変更
# x ・・・ 実行権限
RUN chmod +x wait-for.sh
# EXPOSE 命令は、実際にポートを公開するわけではない。
# これは、イメージを構築する人とコンテナを実行する人の間で、どのポートを公開するかについての一種の文書として機能する。
# 今回、docker-compose.yml において、api コンテナは 8080 ポートを解放するため「8080」とする。
EXPOSE 8080
# バイナリファイルの実行
CMD [ "/app/main" ]
注目すべき点は、FROM が2回登場する点です。
第一の FROM では、
FROM golang:1.16-alpine3.13 AS builder
とあるように、golang:1.16-alpine3.13 を Base Image としています。この golang:1.16-alpine3.13 は Go 言語の環境がインストールされています。
そして、第二の FROM では、
FROM alpine:3.13
とあるように、alpine:3.13 を Base Image としています。この alpine:3.13 には、Go 言語の環境はインストールされていません。しかし、バイナリファイルを実行する環境は整っています。
したがって、
RUN go build -o main /app/main.go
上記コードで生成されたバイナリファイルをコピーして実行することが出来ます。
# Build stage からビルドされた main だけを Run stage にコピーする。
COPY /app/main .
--from=builder の builder は、第一の FROM である
FROM golang:1.16-alpine3.13 AS builder
の builder という名前と一致させる必要があります。
最後に、上記の流れを図に表すと下記のようになります。
Multi-stage build を実行することで、イメージサイズを小さくすることが出来ます。
下記コマンドを実行して、イメージサイズを確認してみましょう。
$ docker iamges
REPOSITORY TAG IMAGE ID CREATED SIZE
multi-stage-build_api latest b1fa72be0f91 3 hours ago 20.6MB
postgres 12.8 108ccc7c5fa3 6 months ago 371MB
multi-stage-build_api のイメージサイズが、20.6MB となっています。
Multi-stage build を使わない場合
最後に、Multi-stage build を使わない場合に、イメージサイズがどれほどになるのかを確認しておきましょう。
下記コマンドを実行して、コンテナを削除してください。
$ docker-compose down
また、下記コマンドで multi-stage-build_api の IMAGE ID を確認して image を削除しましょう。
$ docker images
$ docker rmi -f <IMAGE ID>
そして、Dockerfile を下記のように書き換えてください。
# Build stage
# golang:<version>-alpine は、Alpine Linux プロジェクトをベースにしている。
# イメージサイズを最小にするため、git、gcc、bash などは、Alpine-based のイメージには含まれていない。
FROM golang:1.16-alpine3.13 AS builder
# 作業ディレクトリの定義をする。今回は、app ディレクトリとした。
WORKDIR /app
# go.mod と go.sum を app ディレクトリにコピー
COPY go.mod go.sum ./
# 指定されたモジュールをダウンロードする。
RUN go mod download
# src ディレクトリの中身を app フォルダにコピーする
COPY . .
# 実行ファイルの作成
# -o はアウトプットの名前を指定。
# ビルドするファイル名を指定(今回は main.go)。
RUN go build -o main /app/main.go
# wait-for.sh の権限を変更
# x ・・・ 実行権限
RUN chmod +x wait-for.sh
# EXPOSE 命令は、実際にポートを公開するわけではない。
# これは、イメージを構築する人とコンテナを実行する人の間で、どのポートを公開するかについての一種の文書として機能する。
# 今回、docker-compose.yml において、api コンテナは 8080 ポートを解放するため「8080」とする。
EXPOSE 8080
# バイナリファイルの実行
CMD [ "/app/main" ]
上記の Dockerfile では、Build stage のみ存在しています。
それでは、下記コマンドを実行して、アプリケーションを立ち上げましょう。
$ docker-compose up
正常にアプリケーションが立ち上がったら、イメージサイズを確認します。
REPOSITORY TAG IMAGE ID CREATED SIZE
multi-stage-build_api latest 9c21dc557a66 9 seconds ago 446MB
postgres 12.8 108ccc7c5fa3 6 months ago 371MB
上記のように、multi-stage-build_api のイメージサイズが 446MB に膨れ上がっています。
これは、Go 言語の実行環境がインストールされている golang:1.16-alpine3.13 をそのまま使用したためです。
Multi-stage build を使った場合には、イメージサイズが 20.6MB でしたので、Multi-stage build を使わない場合、イメージサイズは 約22倍 になってしまいます。
Multi-stage build の有効性についてご理解いただけたでしょうか。
第 3 章 まとめ
最後まで読んでいただき有難うございます。
Go 言語で Docker を使うときには、是非 Multi-stage build に挑戦してみてください。
もしも誤植等ありましたらコメントしていただけると幸いです。
補遺
docker-compose.yml についての解説をしておきます。
今回は、データベースである PostgreSQL と、API として機能する Go の 2 つのコンテナを定義して実行します。
services.api.build において、Dockerfile のあるディレクトリのパスを指定しています。
version: "3.8"
services:
postgres:
# コンテナ名を指定
container_name: postgres
image: postgres:12.8
# OSの起動時にコンテナを起動させる
restart: always
env_file:
- .env
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
ports:
- 5432:5432
volumes:
- db:/var/lib/postgresql/data
api:
# コンテナ名を指定
container_name: api
build:
# 「.」は本docker-compose.ymlがあるディレクトリ(現在のディレクトリ)を指す
# 今回は、Dockerfile をルートディレクトリに配置する
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
POSTGRES_HOST: "${POSTGRES_HOST}"
# depends_on は起動順を制御するだけである。
# したがって、postgres コンテナが起動してから api コンテナが起動するという保証はされない
depends_on:
- postgres
# entrypoint を設定すると、Dockerfile の ENTRYPOINT で設定されたデフォルトのエントリポイントが上書きされ、イメージのデフォルトコマンドがクリアされる。
# つまり、Dockerfile に CMD 命令があれば、それは無視される。
# よって、docker-compose.yml においても実行するコマンドを明示的に指定する必要がある。
entrypoint: ["/app/wait-for.sh", "postgres:5432", "--"]
command: ["/app/main"]
volumes:
db:
Discussion