cli-tool-docker のイメージサイズ調査
Posted:
前提
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-fullcopy 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.0 と mono-devel は存在する。
ただし日常 CLI コンテナで常時必要なものではなく、サイズ影響が大きいため削除した。
ilspycmd が必要な場合は .NET 10 系に寄せる必要があるため、普段使いの image に戻すより、解析用途の別 image/profile に分ける方が構成が単純になる。
Volta の npm cache を同じ RUN で消す
Volta は Node.js と npm global CLI のバージョン管理に使っている。
この image では node@24.2.0 と、claude、gemini、codex、clawdbot、jsonlint、markdownlint を 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
navi と zoxide を使うだけなら 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/rustup 約 1.46GB と /opt/cargo 約 177MB を 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に閉じ込めた。 toolsstage で 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 分割した方が、ビルド時間とローカルディスク消費の両方を読みやすくできる。