cli-tool-docker のイメージサイズ調査

前提

cli-tool-docker は日常作業用の CLI をまとめたコンテナなので、単純なアプリ実行用イメージより大きくなる。 ただし Dockerfile の構造上、builder から成果物以外をコピーしてしまうと、マルチステージビルドの利点を失いやすい。

調査対象は Ubuntu 26.04 版のローカルビルド。

docker build --progress=plain -t cli-tool-docker:ubuntu-26.04-test .

実測値

削減前:

content size: 4.58GB
Docker 29 local disk usage: 18.1GB

削減後:

image id: 83cff82c6680
inspect Size: 2921887630 bytes
rootfs layer count: 30
content size: 2.92GB
Docker 29 local disk usage: 11.9GB

低リスクな layer 数削減後:

image id: d5d9b0068ed3
rootfs layer count: 25
Docker 29 local disk usage: 11.9GB

artifacts stage 集約後:

image id: c673ff63d2c0
rootfs layer count: 15
Docker 29 local disk usage: 11.9GB

差分:

  • content size は 4.58GB から 2.92GB へ減少、約 1.66GB 削減
  • local disk usage は 18.1GB から 11.9GB へ減少、約 6.2GB 削減
  • 低リスクな layer 数削減では RootFS layer は 30 から 25 へ減ったが、表示サイズは 11.9GB のままだった
  • artifacts stage 集約後は RootFS layer は 25 から 15 へ減ったが、表示サイズは 11.9GB のままだった

content size はイメージ内容のサイズとして比較しやすい。 Docker 29 の DISK USAGE は containerd snapshotter の展開済みデータやローカルストア都合が入るため、同じマシン上の比較値として見る。

調査に使ったコマンド

docker image ls cli-tool-docker:ubuntu-26.04-test
docker image ls --tree cli-tool-docker:ubuntu-26.04-test
docker image inspect cli-tool-docker:ubuntu-26.04-test --format '{{.Id}} {{.Size}} {{len .RootFS.Layers}}'
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' cli-tool-docker:ubuntu-26.04-test

コンテナ内部の大きいディレクトリは次で見る。

docker run --rm --entrypoint bash cli-tool-docker:ubuntu-26.04-test -lc \
  'du -xh -d2 /opt /usr/local /usr/lib /usr/share /root 2>/dev/null | sort -hr | head -100'

大きくなっていた理由

削減前の大きい layer は次の通り。

  • apt package install layer: 約 5GB
  • Volta と npm global install layer: 約 3.14GB
  • builder からコピーした /opt/rustup: 約 1.46GB
  • Julia install layer: 約 1.09GB。利用根拠が見つからなかったため、後続の削減で削除した
  • Google Cloud SDK layer: 約 879MB
  • aqua package cache layer: 約 759MB
  • nerdctl-full copy layer: 約 570MB
  • builder からコピーした /opt/cargo: 約 177MB

FROM tools AS final のように stage を継承している場合、tools までに入った apt package や language runtime はそのまま final image に残る。 マルチステージビルドで小さくなるのは、別 stage のビルド作業ディレクトリを final にコピーしない場合だけ。

また Docker の layer は差分なので、後続の RUN rm -rf ... では前の layer に入った bytes は消えない。 cache や一時ファイルは、生成した同じ RUN の末尾で消す必要がある。

今回の削減

dotnet-sdk と mono-devel を外す

Ubuntu 26.04 では dotnet-sdk-10.0mono-devel は存在する。 ただし日常 CLI コンテナで常時必要なものではなく、サイズ影響が大きいため削除した。

ilspycmd が必要な場合は .NET 10 系に寄せる必要があるため、普段使いの image に戻すより、解析用途の別 image/profile に分ける方が構成が単純になる。

Volta の npm cache を同じ RUN で消す

Volta は Node.js と npm global CLI のバージョン管理に使っている。 この image では node@24.2.0 と、claudegeminicodexclawdbotjsonlintmarkdownlint を Volta 配下に入れている。

Volta 経由の npm global install 後に、同じ RUN 内で次を消す。

rm -rf /root/.npm /root/.cache/node-gyp

これで Volta layer は約 3.14GB から約 2.2GB へ減った。 LLM 系 npm CLI 自体は依然として大きいので、特に clawdbot のような native dependency を持つ package は今後の分割候補になる。

/opt/rustup と /opt/cargo をコピーしない

以前は cargo stage から次を final image へコピーしていた。

COPY --from=cargo-install /opt/cargo /opt/cargo
COPY --from=cargo-install /opt/rustup /opt/rustup

navizoxide を使うだけなら Rust toolchain は runtime に不要なので、builder stage で /out/bin へ実行ファイルだけを集める。

install -Dm0755 /opt/cargo/bin/navi /out/bin/navi
install -Dm0755 /opt/cargo/bin/zoxide /out/bin/zoxide

final image ではこれだけをコピーする。

COPY --from=cargo-install /out/bin/ /usr/local/bin/

この copy layer は約 6.34MB になり、/opt/rustup1.46GB/opt/cargo177MB を final image から外せた。

Julia を外す

juliaup で入れていた Julia runtime は /root/.julia 配下に約 1.1GB 置かれていた。 この repository、関連 Hugo content、関連 Ansible repository を rg した範囲では、Julia の具体的な利用箇所は見つからなかった。 このため日常 CLI image からは削除した。

Julia 削除前後では、content size は 3.22GB から 2.92GB へ、local disk usage は 13.3GB から 11.9GB へ減った。

aqua package store は残す

/usr/local/aqua/bin/* は個別の実行ファイルではなく ../aqua-proxy への symlink。 実体は /usr/local/aqua/pkgs 配下に展開されている。

/usr/local/aqua/pkgs を消しても、ネットワークがあれば初回実行時に aqua が再ダウンロードして動く。 ただし --network none で確認すると、ecspresso version は GitHub API に到達できず失敗した。 つまりこれは「消しても完全に無影響な cache」ではなく、消すと first-use がネットワーク依存になる package store と見た方がよい。

layer 数を 30 から 25 に減らす

サイズ削減とは別に、低リスクな layer 数削減も行った。 ここでは常用 image としての差分配布や調査性を優先し、FROM scratch へ rootfs を丸ごとコピーする squash 風の構成は使わなかった。

実行した build:

docker build --progress=plain -t cli-tool-docker:layer-reduction-test .
docker inspect cli-tool-docker:layer-reduction-test --format '{{len .RootFS.Layers}} layers'
docker image ls cli-tool-docker --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.Size}}'

結果:

cli-tool-docker:layer-reduction-test d5d9b0068ed3 11.9GB
cli-tool-docker:ubuntu-26.04-test    83cff82c6680 11.9GB

layer-reduction-test: 25 layers
ubuntu-26.04-test:    30 layers

入れた変更:

  • COPY entrypoint.bash ... の後に RUN chmod +x ... する代わりに、COPY --chmod=0755 を使った。
  • Stylua の download、unzip、install、一時ディレクトリ削除を同じ RUN に閉じ込めた。
  • tools stage で base stage と重複していた apt install を削った。実測では約 61KB の layer だったが、意味のない layer だった。
  • ghq は aqua 管理版が PATH 上で使われていたため、手動 download して /usr/local/bin/ghq に置く処理を削った。

確認した PATH:

ghq    /usr/local/aqua/bin/ghq
osc    /usr/local/bin/osc
stylua /usr/local/bin/stylua
cha    /usr/bin/cha
yazi   /usr/local/bin/yazi

この変更では layer 数は減ったが、image size にはほぼ効かなかった。 理由は、削った layer が chmod や cleanup のような小さい layer であり、残っている大物は apt、Volta、Google Cloud SDK、aqua package store、nerdctl-full だから。

artifacts stage で final の COPY layer をまとめる

pull 頻度が少ない運用なら、差分配布効率より layer 数削減を優先してもよい。 そこで builder stage からの成果物 copy を一度 artifacts stage に集約し、final stage では 1 回だけ copy する形にした。

FROM scratch AS artifacts

COPY --from=neovim-build     /opt/neovim            /opt/neovim
COPY --from=tmux-build       /opt/tmux              /opt/tmux
COPY --from=nerdctl-install  /out/bin/              /usr/local/bin/
COPY --from=lazygit          /out/lazygit           /usr/local/bin/lazygit
COPY --from=cni-install      /opt/cni               /opt/cni
COPY --from=cargo-install    /out/bin/              /usr/local/bin/
COPY --from=go-cli-install   /out/osc               /usr/local/bin/osc
COPY --from=yazi-install     /out/bin/yazi          /usr/local/bin/yazi
COPY --from=yazi-install     /out/bin/ya            /usr/local/bin/ya
COPY --from=yazi-install     /out/share/            /usr/local/share/
COPY --chmod=0755 entrypoint.bash /usr/local/bin/entrypoint.bash

FROM tools AS final

COPY --from=artifacts / /
ENTRYPOINT ["/usr/local/bin/entrypoint.bash"]

実行した build:

docker build --progress=plain -t cli-tool-docker:artifacts-stage-test .
docker inspect cli-tool-docker:artifacts-stage-test --format '{{len .RootFS.Layers}} layers'
docker history --human --format '{{.CreatedBy}}\t{{.Size}}' cli-tool-docker:artifacts-stage-test

結果:

cli-tool-docker:artifacts-stage-test c673ff63d2c0 11.9GB
artifacts-stage-test: 15 layers
layer-reduction-test: 25 layers
ubuntu-26.04-test:    30 layers

docker history では final の成果物 copy が次の 1 layer にまとまった。

COPY / / # buildkit  749MB

この変更で nerdctl-full、CNI、Neovim、tmux、yazi などの成果物は 1 つの大きい layer にまとまる。 そのため、成果物の一部だけが変わった場合も約 749MB の layer が差分対象になりやすい。 pull 頻度が少ない家庭内検証環境なら許容しやすいが、頻繁に配布する image では個別 COPY のままにする判断もある。

確認したこと

スモークテストでは次を確認した。

  • dotnet, mono, ilspycmd, julia は PATH 上にない
  • navi 2.24.0 が動作する
  • zoxide 0.9.9 が動作する
  • 7z, cha, w3m, nvim, tmux, yazi, nerdctl, ecspresso が動作する
  • /opt/rustup, /opt/cargo, /root/.julia, /root/.npm, /root/.cache/node-gyp は存在しない
  • Julia は日常用 CLI コンテナ内での利用根拠が見つからなかったため削除した

package としても次は入っていない。

dpkg-query -W dotnet-sdk-10.0 mono-devel zoxide

残っている大物

削減後も大きいディレクトリは残る。

2.5G  /usr/lib
2.2G  /usr/local
2.2G  /opt
2.1G  /opt/volta
839M  /usr/local/google-cloud-sdk
703M  /usr/local/aqua
658M  /usr/local/bin
607M  /usr/share
552M  /usr/lib/python3
316M  /usr/lib/rust-1.93
287M  /usr/lib/jvm

次に削るなら、効果が大きい順に次を検討する。

  • Google Cloud SDK を常用 image から分ける
  • Volta で入れる LLM 系 npm CLI を用途別 image に分ける
  • nerdctl-full ではなく必要な runtime binary だけに絞る
  • /usr/local/aqua/pkgs を消す場合は、aqua 管理 CLI の初回実行がネットワーク依存になることを許容できるか見直す
  • Java/TeX を日常 CLI image から分ける

家庭内検証環境なら、単一巨大 image で何でも入っている利便性を優先する判断も妥当。 一方で更新頻度が高いもの、native dependency が重いもの、用途が限定されるものは profile 分割した方が、ビルド時間とローカルディスク消費の両方を読みやすくできる。