🐳

【マルチステージビルド】Go 言語で開発したプロダクトのイメージサイズを小さくする

2022/05/07に公開

本記事の目標

こんにちは、FarStep です。
今回は、Docker の Multi-stage build を使って、Go 言語で開発したプロダクトのイメージサイズを小さくする というテーマを扱います。

Multi-stage build とは

Multi-stage build とは何でしょうか。
Multi には、「多くの」・「多重の」・「複数の」といった意味があります。
よって、Multi-stage build とは「複数のステージを用いたビルド」となります。

では、複数のステージを用いる とはどういうことでしょうか。
通常 Docker イメージには、アプリケーションおよび実行に必要なものの他に、イメージのビルドに関わるライブラリ等も含まれています。しかし、本番環境では アプリケーションおよびその実行に必要なもののみ をビルドしたいですよね。
そこで複数のステージを用いると、この悩みが解消されます。

ここで、ステージを二つ用意するとします。
一つ目のステージでは、アプリケーションのビルドを行い Docker イメージを作成します。
二つ目のステージでは、一つ目のステージで作成したイメージの中から必要なものだけをコピーしてきます。

このようにステージを二つ用意することで、最終的な Docker イメージには必要なものだけが含まれるようになるのです。
その結果、イメージサイズが小さくなり、本番環境の運用のパフォーマンスが向上します。

Go 言語を用いた簡単なプロダクトの紹介

今回 Go 言語を用いた簡単なプロダクトとして、REST API を構築したアプリケーションを採用します。プロジェクト全体のコードは、下記をご覧ください。

https://github.com/NaokiYazawa/multi-stage-build

まずは、レポジトリを clone してきます。

$ git clone git@github.com:NaokiYazawa/multi-stage-build.git
$ cd multi-stage-build

次に .env を作成して、PostgreSQL の環境変数を定義しましょう。

$ touch .env
.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 を見てみましょう。
解説をコメントとして入れたので、一行一行追ってみてください。

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 --from=builder /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 --from=builder /app/main .

--from=builderbuilder は、第一の 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 を下記のように書き換えてください。

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 のあるディレクトリのパスを指定しています。

docker-compose.yml
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