cimgというプレフィックスが付いたコンテナーイメージは、継続的インテグレーションでのビルドを想定してCircleCI社が作成したコンテナイメージです。 様々なプログラミング言語、データベース、OSのイメージが提供されており、自由に使えます。
一方、Dockerも様々なプログラミング言語、データベース、OSのイメージとしてDocker Hubに公式イメージとして提供しています。
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のドキュメントにまとめられています(非公式ですが、日本語のドキュメントもあります)。
- https://docs.docker.com/develop/develop-images/multistage-build/
- https://docs.docker.jp/develop/develop-images/multistage-build.html
マルチステージビルドを試す
最初に取り上げた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
ビルドしたイメージが動かなかった原因
調べてみると、次のような記事を見つけました。
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を意識することなく実行できるようになるかもしれません。
Alpine Linuxイメージではなくbusyboxイメージを使う
今回は例としてAlpine Linuxのイメージを使ってGoアプリケーションを実行しましたが、 ビルド済みのGoアプリケーションはもっと軽量なコンテナーイメージベースでも動かせます。 Alpine Linuxを使っている時点でかなりイメージサイズ的には小さいものの、現状はLibc関連でちょっと面倒な記述が必要ですし、 もっと簡単に実行することはできないものでしょうか。
busyboxイメージを使えば簡単です。
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