お疲れ様です。ウォンテッドリーでバックエンドエンジニアをしている水野(@fetburner)です。今回の記事は夏のアドベントカレンダー6日目という事で、前日の原(@chloe463)の記事同様お楽しみ頂ければ幸いです。
Kubernetes をはじめとしたコンテナオーケストレーションシステムの興隆もあり、最近では素の Docker を使うことも少なくなってしまいましたが、コンテナ作成に必要なコンテナイメージの重要性は未だ損なわれていないように思われます。保守性のために Amazon ECS や Cloud Run のようなマネージドサービスを採用している企業も多いと思いますが、それらサービスでもコンテナ作成のためにコンテナイメージを必要としていますよね。
何かと入用になるコンテナイメージですが、ただ動けば良いというものでもなく、必要なファイルのみ含んだ最小限の構成とするのが良いと言われています。コンテナイメージはコンテナを立てるインスタンス毎にダウンロードしストレージに展開して使う訳ですから、必要以上に大きなイメージを使った場合にはコンテナ数に比例した分多くネットワーク通信量とストレージ容量が必要になってしまいます。
本記事では、nginx を題材としてマルチステージビルドを用いた軽量なコンテナイメージの作成方法を解説します。一般に、マルチステージビルドを前提とした軽量なベースイメージ(distroless 等)を採用する場合には、依存ライブラリを静的にリンクしてシングルバイナリの実行ファイルを作成する事が多いように思われますが、共有ライブラリを必要とする実行ファイルを使ってもマルチステージビルドで軽量なイメージを作成できることも解説します。
従来の Dockerfile の書き方
Docker 公式が用意している nginx のコンテナイメージがそうであるように、一般にコンテナイメージ作成にあたって Dockerfile を書くとなると、まず alpine や ubuntu のような Linux ディストリビューションの名前を冠したベースイメージから開始してパッケージシステムを使って必要なファイルを入れていく流れを取るのではないでしょうか。
FROM alpine:3.21
LABEL maintainer="NGINX Docker Maintainers <docker-maint@nginx.com>"
ENV NGINX_VERSION 1.28.0
ENV PKG_RELEASE 1
ENV DYNPKG_RELEASE 1
RUN set -x \
&& addgroup -g 101 -S nginx \
&& adduser -S -D -H -u 101 -h /var/cache/nginx -s /sbin/nologin -G nginx -g nginx nginx \
&& apkArch="$(cat /etc/apk/arch)" \
&& nginxPackages=" \
nginx=${NGINX_VERSION}-r${PKG_RELEASE} \
" \
&& apk add --no-cache --virtual .checksum-deps \
openssl \
一応こうしたシングルステージビルドでも RUN
内のコマンドを極力 &&
で繋げてレイヤを削減し、パッケージマネージャ等のキャッシュを残さないよう気を遣えばある程度はビルド後のコンテナイメージを軽量に保つ事ができるのですが、コンテナイメージがレイヤ構造を取る都合上 ubuntu の様なベースイメージに含まれていたファイルは残り続ける事になります。使いもしないパッケージシステムをコンテナ毎にコピーするのはストレージの無駄ですし、万が一悪意ある攻撃者にコンテナへ侵入された時の事を考えると、セキュリティ的にも好ましいことではありません。
それでは、パッケージシステム等のビルドに必要なファイルを含まないベースイメージを採用してコンテナイメージを作るにはどうすれば良いでしょうか?ベースイメージでできる事は限られているので、何処か別の所からファイルを持ってくる必要がありそうですね。
マルチステージビルドの利用
最終イメージにビルドに必要なファイルを含ませたくはないが、それが無ければコンテナイメージを作れないジレンマを解消するテクニックとして、一般にマルチステージビルドが知られています。ビルドツール等を含んだ別のイメージで必要なファイルを作成した後、それを軽量なイメージに持ってくる訳ですね。
FROM golang:1.24 AS build
WORKDIR /src
COPY <<EOF /src/main.go
package main
import "fmt"
func main() {
fmt.Println("hello, world")
}
EOF
RUN go build -o /bin/hello ./main.go
FROM scratch
COPY --from=build /bin/hello /bin/hello
CMD ["/bin/hello"]
Docker 公式ドキュメントの例で最終的な成果物のベースイメージに FROM scratch
を用いているように、ビルド用のベースイメージを別に用いるようになった今、最終成果物に用いるベースイメージはコンテナ上で動くアプリケーションが必要としないのであればパッケージシステムはおろかシェルすら含まなくて良い訳です。その様な発想で作られたイメージとしては、Google が提供している distroless なんかが有名ですね。
マルチステージビルドを使えば distroless の様に軽量なベースイメージを採用できるようですし、ひとつ例として Docker 公式の提供する nginx のコンテナイメージを軽量化できないか試してみましょう。端末を開いて which nginx
を実行した感じ、nginx の実行ファイルは /usr/sbin/nginx
に居るみたいなのでこれを distroless にコピーしてイメージを作ってみます。
FROM nginx:1.28.0-bookworm AS nginx-image
FROM gcr.io/distroless/base-debian12
COPY --from=nginx-image /usr/sbin/nginx /usr/sbin/nginx
CMD ["nginx" "-g" "daemon off;"]
こうして作成したイメージを基にコンテナを起動してみたのですが、何やらエラーメッセージを吐いて終了していますね。
nginx: error while loading shared libraries: libcrypt.so.1: cannot open shared object file: No such file or directory
そう、mv だの mkdir だの星の数ほどある実行ファイルに C 標準ライブラリの様な万人が使う実装を含めていてはストレージがいくらあっても足りないので、現代的な OS ではこうしたコードを共有ライブラリとして別のファイルに括り出し、実行時にリンクして使い回す機構が備わっているのです。プログラムの実行に必要な共有ライブラリは ldd
コマンドで列挙できるので、先程の nginx の実行に必要だったファイルを確かめてみると、libcrypt.so.1
以外にも libpcre2-8.so.0
だとか他にも色々と入用のようですね。
root@nginx-1:/# ldd $(which nginx)
linux-vdso.so.1 (0x00007ffcaf3d6000)
libcrypt.so.1 => /lib/x86_64-linux-gnu/libcrypt.so.1 (0x00007f4f3d179000)
libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007f4f3d0df
000)
libssl.so.3 => /lib/x86_64-linux-gnu/libssl.so.3 (0x00007f4f3d036000)
libcrypto.so.3 => /lib/x86_64-linux-gnu/libcrypto.so.3 (0x00007f4f3cbb000
0)
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f4f3cb91000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4f3c9ae000)
/lib64/ld-linux-x86-64.so.2 (0x00007f4f3d402000)
(linux-vdso.so.1
と /lib64/ld-linux-x86-64.so.2
はさておき)これくらいの数のファイルであれば手作業で指定して最終成果物に用いるイメージへコピーしてしまっても良い気がしますが、厄介なのは共有ライブラリもまた共有ライブラリに依存する可能性がある事です。こうなっては手作業で必要な共有ライブラリを列挙するのは大変に手間なので、distroless の様に軽量なベースイメージを使う場合はライブラリを静的にリンクしてシングルバイナリとしてビルドした実行ファイルを入れている人も多いみたいですね。
root@nginx-1:/# ldd /lib/x86_64-linux-gnu/libssl.so.3
linux-vdso.so.1 (0x00007ffdd39e3000)
libcrypto.so.3 => /lib/x86_64-linux-gnu/libcrypto.so.3 (0x00007f9b1940800
0)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9b19227000)
/lib64/ld-linux-x86-64.so.2 (0x00007f9b1993d000)
とはいえ世の中のライブラリが全て静的リンクできる形で提供されているとも限らない(プロプライエタリなデバイスドライバなんかは特に)ですし、ディストリビューション公式が配布している実行ファイルをわざわざビルドし直すのも不毛なので、プログラムとその依存している共有ライブラリを列挙する上手い手立てはないものでしょうか?一応 ldd
を使ってシェル芸しても不動点を求められそうですが、大変な割に車輪の再発明っぽいのであまりやりたくないですよね…
magicpak
こうした万人の抱える課題には大抵賢い人が解決法を見出しているもので、magicpak というツールを使えば与えられた実行ファイルを解析し、その必要とする共有ライブラリを全て列挙してくれます。
試しに magicpak を使って、nginx のコンテナイメージ軽量化の続きをやってみましょう。gcr.io/distroless/base-debian12
には glibc や libssl といったよく使う共有ライブラリが含まれていた訳ですが、必要な共有ライブラリは magicpak が解析してくれるのであれば何も入っていない FROM scratch
のベースイメージを採用しても良さそうです。
FROM magicpak/debian:magicpak1.4.0 AS magicpak-image
FROM nginx:1.28.0-alpine-slim AS nginx-image
RUN --mount=type=bind,from=magicpak-image,target=/mnt \
set -eux && \
apk add gcc libc-dev && \
/mnt/bin/magicpak -v "$(which nginx)" /asset
FROM scratch
COPY --from=nginx-image /asset /
CMD ["/usr/sbin/nginx", "-g", "daemon off;"]
この Dockerfile を基にコンテナイメージをビルドしてみると、なるほど確かに nginx の必要としている共有ライブラリが解析されているらしいログが出力されています。ldd
コマンドを連打するのも中々骨が折れるので、自動的に依存関係を洗い出してくれるのは嬉しいですね。
#14 6.402 INFO action: bundle shared object dependencies exe=/usr/sbin/nginx
#14 7.262 WARN exe: interpreter could not be found. static or compressed executable?
#14 7.263 WARN exe: requesting dynamic libraries of the executable without the interpreter
#14 7.283 WARN exe: interpreter could not be found. static or compressed executable?
#14 7.283 WARN exe: requesting dynamic libraries of the executable without the interpreter
#14 7.303 WARN exe: interpreter could not be found. static or compressed executable?
#14 7.303 WARN exe: requesting dynamic libraries of the executable without the interpreter
#14 7.320 WARN exe: interpreter could not be found. static or compressed executable?
#14 7.320 WARN exe: requesting dynamic libraries of the executable without the interpreter
#14 7.331 WARN exe: interpreter could not be found. static or compressed executable?
#14 7.332 WARN exe: requesting dynamic libraries of the executable without the interpreter
#14 7.333 INFO action: bundle executable exe=/usr/sbin/nginx install_path=None
#14 7.334 INFO action: emit dest=/asset
#14 7.334 INFO action: emit: creating destination dir as it does not exist dest=/asset
#14 7.336 INFO emit: copy from=/usr/lib/libssl.so.3 target=/asset/usr/lib/libssl.so.3
#14 7.338 INFO emit: link link=/usr/lib/libpcre2-8.so.0.12.0 target=/asset/usr/lib/libpcre2-8.so.0
#14 7.339 INFO emit: copy from=/usr/lib/libpcre2-8.so.0.12.0 target=/asset/usr/lib/libpcre2-8.so.0.12.0
#14 7.339 INFO emit: copy from=/usr/lib/libcrypto.so.3 target=/asset/usr/lib/libcrypto.so.3
#14 7.343 INFO emit: copy from=/usr/sbin/nginx target=/asset/usr/sbin/nginx
#14 7.344 INFO emit: copy from=/lib/ld-musl-aarch64.so.1 target=/asset/lib/ld-musl-aarch64.so.1
#14 7.345 INFO emit: link link=/usr/lib/libz.so.1.3.1 target=/asset/usr/lib/libz.so.1
#14 7.345 INFO emit: copy from=/usr/lib/libz.so.1.3.1 target=/asset/usr/lib/libz.so.1.3.1
それではこのイメージを使ってコンテナを立ててみましょう。共有ライブラリの問題が解決した今、nginx は正常に立ち上がってくれるでしょう…
2025/07/01 14:11:57 [emerg] 1#1: open() "/etc/nginx/nginx.conf" failed (2: No such file or directory)
nginx: [alert] could not open error log file: open() "/var/log/nginx/error.log" failed (2: No such file or directory)
と思いきや設定ファイルとログの不備で落ちてしまいました。流石に Linux の実行形式にはプログラムがどのファイルを開くかの情報までは含まれていないので magicpak も知る由もなく、/etc
やら /var
やらの中身は手動でコピーしてやる必要があります。
FROM magicpak/debian:magicpak1.4.0 AS magicpak-image
FROM nginx:1.28.0-alpine-slim AS nginx-image
RUN --mount=type=bind,from=magicpak-image,target=/mnt \
--mount=type=cache,target=/var/cache/apk,sharing=locked \
set -eux && \
apk add gcc libc-dev && \
/mnt/bin/magicpak -v "$(which nginx)" /asset
FROM scratch
COPY --from=nginx-image /asset /
COPY --from=nginx-image /etc/ssl /etc/ssl
COPY --from=nginx-image /etc/nginx /etc/nginx
COPY --from=nginx-image /etc/group /etc/group
COPY --from=nginx-image /etc/passwd /etc/passwd
COPY --from=nginx-image /var/log/nginx /var/log/nginx
COPY --from=nginx-image /var/cache/nginx /var/cache/nginx
RUN --mount=type=bind,from=nginx-image,target=/mnt ["/mnt/bin/busybox", "mkdir", "-m", "755", "/run"]
RUN --mount=type=bind,from=nginx-image,target=/mnt ["/mnt/bin/busybox", "ln", "-sf", "/run", "/var/run"]
RUN --mount=type=bind,from=nginx-image,target=/mnt ["/mnt/bin/busybox", "ln", "-sf", "/dev/stderr", "/var/log/nginx/error.log"]
RUN --mount=type=bind,from=nginx-image,target=/mnt ["/mnt/bin/busybox", "ln", "-sf", "/dev/stdout", "/var/log/nginx/access.log"]
EXPOSE 80
STOPSIGNAL SIGQUIT
CMD ["/usr/sbin/nginx", "-g", "daemon off;"]
途中少し躓きましたが、これで軽量なベースイメージを使った nginx のコンテナイメージが完成しました!
2025/07/01 14:30:50 [notice] 1#1: start worker process 13
2025/07/01 14:30:50 [notice] 1#1: start worker process 12
2025/07/01 14:30:50 [notice] 1#1: start worker process 11
2025/07/01 14:30:50 [notice] 1#1: start worker process 10
2025/07/01 14:30:50 [notice] 1#1: start worker processes
2025/07/01 14:30:50 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1048576:1048576
2025/07/01 14:30:50 [notice] 1#1: OS: Linux 4.4.302+
2025/07/01 14:30:50 [notice] 1#1: built by gcc 14.2.0 (Alpine 14.2.0)
2025/07/01 14:30:50 [notice] 1#1: nginx/1.28.0
2025/07/01 14:30:50 [notice] 1#1: using the "epoll" event method
さて、これだけコンテナイメージの軽量化に精を出したのだからその成果も気になる所です。docker image ls
で得られる圧縮済のサイズで比較すると、nginx:1.28.0-alpine-slim
が 11.9MB に対し今回作成したイメージの ghcr.io/fetburner/nginx
が 9.79MB でした。元の公式イメージがよくチューニングされているのか、何だか苦労の割に大して軽くなっていないような気がしないでもないですが、まあ極限までイメージの中身を切り詰めた事でセキュリティ的には堅牢になったので良しとしましょう。
$ sudo docker image ls | grep nginx
ghcr.io/fetburner/nginx latest 05e3fa5137cd 3 weeks ago 9.79MB
nginx 1.28.0-alpine-slim 5b3e72e43735 2 months ago 11.9MB
まとめ
本記事では、nginx を題材としてマルチステージビルドを用いた軽量な Docker イメージの作成方法を解説しました。元々公式が出している alpine-slim
のイメージサイズが相当小さかったのもあって見た目のインパクトの大きな軽量化はできませんでしたが、パッケージシステムはおろかシェルすら含まないコンテナは悪意ある攻撃者を寄せ付けない事でしょう。
また、一般にマルチステージビルドを前提とした軽量なベースイメージを採用する場合には依存ライブラリを静的にリンクしてシングルバイナリの実行ファイルを作成する事が多いように思われますが、共有ライブラリを必要とする実行ファイルを使ってもマルチステージビルドで軽量なイメージを作成できることも解説しました。世の中のライブラリが全て静的リンクできるものであれば苦労はしないのですが、プロプライエタリなデバイスドライバであったりだとか共有ライブラリでしか提供されていないものもある中でなおコンテナイメージを軽量化しなければならないとなった時、本記事で紹介した magicpak は威力を発揮してくれる事でしょう。
夏のアドベントカレンダーの明日7日の担当は朴(@jihyun_park_0)で、タイトルは「『伝わる』GitHub PRと『育てる』レビューコメントの書き方」です。お楽しみに!