とことんDevOps | 日本仮想化技術のDevOps技術情報メディア

DevOpsに関連する技術情報を幅広く提供していきます。

日本仮想化技術がお届けする「とことんDevOps」では、DevOpsに関する技術情報や、日々のDevOps業務の中での検証結果、TipsなどDevOpsのお役立ち情報をお届けします。
主なテーマ: DevOps、CI/CD、コンテナ開発、IaCなど
読者登録と各種SNSのフォローもよろしくお願いいたします。

コンテナーイメージのマルチステージビルドを試す

cimgというプレフィックスが付いたコンテナーイメージは、継続的インテグレーションでのビルドを想定してCircleCI社が作成したコンテナイメージです。 様々なプログラミング言語、データベース、OSのイメージが提供されており、自由に使えます。

circleci.com

一方、Dockerも様々なプログラミング言語、データベース、OSのイメージとしてDocker Hubに公式イメージとして提供しています。

hub.docker.com

cimgとDcoker提供のイメージとの違いは、CircleCIの処理で使いやすいようにさまざまなバイナリーなどが標準で含まれている点です。 例えば次のようなバイナリーが含まれていました(ほんの一部)。

  • git
  • docker
  • gcc
  • make
  • vim
  • nano
  • zip/unzip
  • xz

元々のベースイメージに対して色々なバイナリーを導入済みなので、当然ながらそれだけイメージのサイズが大きくなります。

Goアプリケーションイメージを作って、コンテナーで動かす

さて、このイメージを使って、作成したGoアプリケーションを実行してみたいと思います。 まずは次のようなdockerfileを作成して...

# syntax=docker/dockerfile:1

FROM cimg/go:1.18

WORKDIR /home/circleci/project
COPY *.go ./
COPY go.mod ./
RUN go build main.go

EXPOSE 8090

CMD [ "/home/circleci/project/main" ]

このアプリケーションはGo言語の1.18ベースで書いたものなので、次の様な内容のgo.modファイルを用意します。

% cat go.mod
module main

go 1.18

次の内容のアプリケーションソースであるmain.goを用意して...

% cat main.go 
package main

import (
    "fmt"
    "net/http"
)

func hello(w http.ResponseWriter, req *http.Request) {

    fmt.Fprintf(w, "hello\n")
}

func headers(w http.ResponseWriter, req *http.Request) {

    for name, headers := range req.Header {
        for _, h := range headers {
            fmt.Fprintf(w, "%v: %v\n", name, h)
        }
    }
}

func main() {

    http.HandleFunc("/hello", hello)
    http.HandleFunc("/headers", headers)

    http.ListenAndServe(":8090", nil)
}

次のようなコマンドを実行してイメージビルドすると、問題なく動作します。

docker build -t mygoenv-cimgbase:latest -f dockerfile-cimgbase .
docker run -d -p 8090:8090 mygoenv-cimgbase:latest
curl http://localhost:8090/hello
hello

ちなみに、このイメージのサイズはおおよそ1.55GBです。 元々のcimg/go:1.18イメージが開発環境込みのイメージなので仕方ありません。

ローカルで動かすだけであればこれでも良いのですが、成果物を共有する場合はこのサイズのイメージがコンテナーレジストリーに共有されることになるので、イメージのPushに時間がかかりますし実行するときもこのサイズのイメージがコンテナー実行環境にダウンロードされることになります。何をするにも実行に時間を要することになり効率的ではありませんよね。

多くの言語でビルドしたバイナリーは実行用のライブラリーが必要ですが、Go言語はビルド済みのバイナリーは1バイナリーで動作します。つまり、ビルドしたバイナリーの実行にGoの言語環境は必要ないということです。

コンテナーイメージのサイズは、できるかぎり小さくしたいですよね。ということで、不要な部分をごっそり削ぎ落としましょう。 この課題を解決する方法がマルチステージビルドというものです。

マルチステージビルドとは

マルチステージビルドは端的に言えば開発環境と実行環境を定義して、最終的にアプリケーションを実行するためのコンテナーイメージサイズを小さくできる、コンテナイメージビルドの手法です。

イメージが小さくなることでメンテナンス性と可搬性が向上します。さらに余計なものがバイナリーに含まれなくなるために、先ほど挙げたメリットの他にセキュリティ的なメリットもあります。

マルチステージビルドの方法はDockerのドキュメントにまとめられています(非公式ですが、日本語のドキュメントもあります)。

マルチステージビルドを試す

最初に取り上げたdockerfileをマルチステージビルド化すると、次のようになります。

# syntax=docker/dockerfile:1

FROM cimg/go:1.18 AS builder
WORKDIR /home/circleci/project
COPY *.go ./
COPY go.mod ./
RUN go build main.go

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /home/circleci/project/main ./

EXPOSE 8090
CMD [ "./main" ]

簡単に説明します。最初のFROM行のブロックはmain.goをビルドしてmainバイナリーを作成しています。 次のFROMから始まるブロックでは、前のブロックで別のコンテナーでビルドしたmainバイナリーを次のコンテナーに持ってきています。 2つ目のブロックのCOPY --from=builderで指定しているbuilderが、1つ目のブロックのAS builderに対応しています。

EXPOSEはコンテナの実行時、指定したネットワークポートをコンテナがリッスンするようにするもので、 ポート8090/TCPで待ち受けするためにEXPOSE 8090と定義しています。 Dockerの場合、TCP/UDPで待ち受けできますが、何も指定しなかった場合はデフォルトでTCPプロトコルで待ち受けします。 ちなみに、EXPOSE 123/udpのように指定すると、UDPプロトコルを利用できます。

また同様に、CMD行でこのイメージを使ってコンテナーを実行したときにアプリケーションが実行されるように、 実行ファイルであるmainバイナリーを指定しています。 このブロックではWORKDIR /root/を指定しており、バイナリーを./にコピーしていることから、./mainを実行するように指定しています。

マルチステージビルドを使ったイメージ作成

イメージは同じようにコマンドを実行すると作成できます。

docker build -t mygoenv-base:latest -f dockerfile .

ビルドしたイメージを使ってアプリケーションを実行します。

docker run -d -p 8090:8090 mygoenv-base:latest

このイメージは8090ポートで待ち受けするように記述していました。 早速curlでアクセスしてみましょう。すると、アクセスできません。

docker psコマンドを実行してみると、現在そのサービスは動いていないようです。

マルチステージビルドに切り替える前は問題なく動いていました。なぜでしょうか。

% curl http://localhost:8090/hello
curl: (7) Failed to connect to localhost port 8090 after 6 ms: Connection refused
% docker ps
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES
% docker ps -a
CONTAINER ID   IMAGE            COMMAND    CREATED              STATUS                          PORTS     NAMES
ec8ee813f6e9   mygoenv:latest   "./main"   About a minute ago   Exited (1) About a minute ago             thirsty_montalcini

ビルドしたイメージが動かなかった原因

調べてみると、次のような記事を見つけました。

stackoverflow.com

Alpine Linuxはデフォルトではglibcではなくmusl libcが入っています。 どちらもCライブラリーであり、musl libcはglibcと互換性があります。 ただ、Goでビルドしたバイナリーは現在glibcを要求します。

そのため、musl libcだとビルドしたGoのバイナリがデフォルトのままでは動かないので、 musl libcのパスをglibcのパスにエイリアスを張ることで、一応実行できるようになるそうです。

実際にdockerfileにワークアラウンドの対応をしてみます。 RUN mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2行のあたりが追加した部分です。

# syntax=docker/dockerfile:1

FROM cimg/go:1.18 AS builder
WORKDIR /home/circleci/project
COPY *.go ./
COPY go.mod ./
RUN go build main.go

FROM alpine:latest
RUN apk --no-cache add ca-certificates
RUN mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2
WORKDIR /root/
COPY --from=builder /home/circleci/project/main ./

EXPOSE 8090
CMD [ "./main" ]

イメージをビルドしてアクセスしてみます。

docker build -t mygoenv-alpine:latest -f dockerfile-alpine .
docker run -d -p 8090:8090 mygoenv-alpine:latest
curl http://localhost:8090/hello
hello

今度はちゃんと動きました。

ちなみに、Goのmusl libcへの対応はIssueが上がっていました。今後の進捗次第ではLibcを意識することなく実行できるようになるかもしれません。

github.com

Alpine Linuxイメージではなくbusyboxイメージを使う

今回は例としてAlpine Linuxのイメージを使ってGoアプリケーションを実行しましたが、 ビルド済みのGoアプリケーションはもっと軽量なコンテナーイメージベースでも動かせます。 Alpine Linuxを使っている時点でかなりイメージサイズ的には小さいものの、現状はLibc関連でちょっと面倒な記述が必要ですし、 もっと簡単に実行することはできないものでしょうか。

busyboxイメージを使えば簡単です。

hub.docker.com

busyboxイメージにはuClibcベース、musl libcベース、そしてglibcベースのイメージが用意されています。 glibcベースのbusyboxイメージを使えば、dockerfileにごちゃごちゃ書く必要がなくなり、スッキリします。

# syntax=docker/dockerfile:1

FROM cimg/go:1.18 AS builder
WORKDIR /home/circleci/project
COPY *.go ./
COPY go.mod ./
RUN go build main.go

FROM busybox:stable-glibc
WORKDIR /root/
COPY --from=builder /home/circleci/project/main ./

EXPOSE 8090
CMD [ "./main" ]

もちろん、ビルドしたイメージは正常に動作します。

docker build -t mygoenv:latest -f dockerfile-busybox .
docker run -d -p 8090:8090 mygoenv:latest
curl http://localhost:8090/hello
hello

サイズ的にはそこまで差はないですが、少しでもイメージを小さくするのがコンテナーベースでアプリケーションを実行するときのセオリーなんだそうです。 「そもそもアプリケーションの実行にパッケージ管理のapkコマンドのような高級なものは不要だ」という意見もあったりします。

REPOSITORY         TAG             IMAGE ID       CREATED             SIZE
alpine             latest          e66264b98777   2 weeks ago         5.53MB
busybox            stable-glibc    ede08e06832e   5 days ago          4.79MB