この記事はセキュリティチームブログリレー3日目 兼 AIチームブログリレー3日目の記事です。
こんにちは、セキュリティチーム 兼 AIチームの横本(@yokomotod)です。
今回は distroless コンテナイメージについて自由研究してみました。
エムスリーでもよく使われている gcr.io/distroless/static などの distroless イメージ、「シェルもパッケージマネージャもない最小限のイメージ」「軽量!なにもないから安全!」といった説明がされますが、今回は実際にその中身を確かめてみようと思います。
ブログリレーの前回記事はこちら
- コンテナベースイメージのおおまかな選択肢
- scratch だけだと何が足りないか
- distroless/static の中身
- イメージの階層構造
- セキュリティ上の意味
- Google Distroless 以外の選択肢
- まとめ
- We are hiring!
コンテナベースイメージのおおまかな選択肢
コンテナのベースイメージには大きく以下のような選択肢があります。
| アプローチ | シェル | パッケージマネージャ | 代表例 | サイズ帯 |
|---|---|---|---|---|
| 完全空 | なし | なし | scratch |
0 |
| distroless 系 | なし | なし | distroless/static | 2-3MB |
| 軽量 OS | あり (busybox) | あり (apk) | Alpine | ~8MB |
| フル OS | あり (bash) | あり (apt) | Debian, Ubuntu | 100MB+ |
distroless の中身を見始める前に、まず scratch だけだと何が困るのかを(いかにも困りそうですが)確認するところから始めてみます。
scratch だけだと何が足りないか
確認のために Go でデモ用の CLI を書きました。hello, serve, https, timezone の 4 つのサブコマンドを持つプログラムと、hello だけの単独バイナリです。
// main.go — 4つのサブコマンドを持つ CLI func main() { switch os.Args[1] { case "hello": // 文字列を出力するだけ fmt.Println("Hello from Go!") case "serve": // HTTP サーバーを起動 http.HandleFunc("/health", ...) http.ListenAndServe(":8080", nil) case "https": // 外部サイトに HTTPS リクエスト resp, err := http.Get("https://example.com") // ... case "timezone": // タイムゾーン利用 loc, err := time.LoadLocation("Asia/Tokyo") // ... } }
// hello/main.go — fmt.Println だけ func main() { fmt.Println("Hello from Go on scratch!") }
scratch で動かしてみる
では動かしてみます。
# Dockerfile FROM golang:1.26 AS builder COPY . . RUN go build -o /hello ./hello/ RUN go build -o /demo . FROM scratch COPY --from=builder /hello /hello COPY --from=builder /demo /demo
これをbuildして出来上がるイメージには本当に自分のバイナリしか入っていません。 *1
まず /hello を動かしてみると…
$ docker run --rm demo-scratch /hello Hello from Go on scratch!
普通に動きました。では /demo はどうでしょうか。
$ docker run --rm demo-scratch /demo hello exec /demo: no such file or directory
こちらはなにやらエラーが出て起動しません。ファイルはあるはずなのに no such file or directory と言われてしまいました。
/demo が存在しないと言われてるメッセージに見えますが、実際は /lib64/ld-linux-x86-64.so.2 が無いというエラーです。
$ file hello ELF 64-bit LSB executable, x86-64, statically linked, ... $ file demo ELF 64-bit LSB executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ...
fileコマンドで見てみると、 demo が動的リンクバイナリになっているのがわかります。 *2
CGO_ENABLED=0 にしてみる
まだ、ここでscratchを諦める必要はありません。 CGO_ENABLED=0 にすると net パッケージが pure Go 実装にフォールバックし、静的バイナリになります。
FROM golang:1.26 AS builder COPY . . RUN CGO_ENABLED=0 go build -o /demo . FROM scratch COPY --from=builder /demo /demo
$ docker run -d -p 8080:8080 demo-scratch-static /demo serve
$ curl http://localhost:8080/health
{"status":"ok"}
今度は FROM scratch でHTTP サーバーが動きました。
しかし HTTPS やタイムゾーン変換も試してみると…
$ docker run --rm demo-scratch-static /demo https Error: ...x509: certificate signed by unknown authority $ docker run --rm demo-scratch-static /demo timezone Error: unknown time zone Asia/Tokyo
HTTPS 接続は CA 証明書がなくて失敗、タイムゾーン変換は tzdata がなくて失敗します。これではAPI呼び出し一つできません。静的バイナリであってもこういった外部のファイルを参照する場面で困ります。
そこで登場するのが distroless image というわけですね。
distroless/static にしてみる
ここでいよいよベースイメージを gcr.io/distroless/static-debian13 にしてみると、無事動いてくれるのが確認できます。
# ... FROM gcr.io/distroless/static-debian13 COPY --from=builder /demo /demo
$ docker run --rm demo-distroless /demo https Status: 200 OK $ docker run --rm demo-distroless /demo timezone UTC: 2026-04-01T09:26:52Z Tokyo: 2026-04-02T18:26:52+09:00
distroless/static の中身
では具体的に何が入っているのか、中を開けて覗いてみます。
イメージの展開方法
イメージの中を見る便利なOSSもありますが、ここでは原始的に docker export でコンテナのファイルシステムを丸ごと取り出してみます。 *3
docker create --name tmp gcr.io/distroless/static-debian13:latest -- /nonexistent docker export tmp | tar -xf - -C distroless-static
ファイル構成
展開結果はこんな感じです。言われている通り、シェルなどの実行可能バイナリは一切ありません。
etc/
debian_version, group, host.conf, hostname, hosts, nsswitch.conf, passwd, ...
mime.types ← 78KB
ssl/certs/ca-certificates.crt ← 224KB, HTTPS 用 CA 証明書バンドル
dpkg/origins/debian
usr/
lib/os-release
share/
common-licenses/ ← GPL-3, Apache-2.0 等 (264KB)
doc/ ← copyright, changelog
zoneinfo/ ← タイムゾーンデータ (3.8MB)
var/lib/dpkg/status.d/ ← dpkg パッケージメタデータ
Alpine や素の Debian と並べてみました。
| イメージ | ファイル数 | パッケージ数 | シェル | パッケージマネージャ |
|---|---|---|---|---|
| distroless/static | 967 | 5 | なし | なし |
| alpine:latest | 522 | 6 | あり (busybox ash) | あり (apk) |
| debian:trixie | 5,438 | 78 | あり (bash) | あり (apt) |
Alpine はファイル数こそ少ないですが、busybox(シェル + 各種ユーティリティ)と apk(パッケージマネージャ)が含まれているのに対して、distroless は「実行可能バイナリが一切ない」というのが明確な違いです。
イメージの階層構造
distroless/static を見ましたが、本当に最小限の distroless/static で動くのは静的バイナリくらいで、他にもさらに必要なパッケージのためにバリエーションが用意されています。
distroless のソースコード *4 を読むと、イメージ間の依存関係はこうなっていました。
static
├── base-nossl (+ libc6)
│ └── java-base → java17, java21, java25
└── base (+ libc6, libssl3)
└── cc (+ libstdc++6)
├── python3
└── nodejs20, nodejs22, nodejs24
各層で追加されるものとサイズの推移です。
| イメージ | static からの追加 | サイズ (compressed) |
|---|---|---|
| static | — | 2.21MB |
| base | glibc, OpenSSL | 24.2MB |
| cc | libstdc++, libgcc | 27.5MB |
| nodejs24 | Node.js バイナリ | 150MB |
Go や Rust のように静的バイナリを生成できる言語なら static で十分(なことが多い)な一方、実行エンジンが C/C++ 製な言語ではどうしても glibc や libstdc++ が必要になる、というわけです。
セキュリティ上の意味
攻撃面の削減
ここまで見てきたように、distroless/static にはシェルも実行可能バイナリも一切ありません。
そのため仮にアプリケーションの脆弱性を突かれてコンテナ内でコード実行される状況に陥ったとしても、攻撃者が使えるツールがほぼありません。通常のコンテナなら sh でシェルを起動し、curl や wget でマルウェアをダウンロードし、chmod で実行権限を付ける…といった操作ができますが、distroless ではそもそもこれらのバイナリが存在しないことで、攻撃の難易度を大きく高めます。
脆弱性トリアージコストの削減
コンテナイメージは脆弱性スキャンを行い日々アップデートが行われていると思いますが、含まれるパッケージが少ないことでそのまま検出件数が減少し、そもそもトリアージすべき脆弱性が存在しないことで運用コストの削減になります。
実際に Trivy で各イメージをスキャンしてみると、2026年4月3日現在で static は 検知ゼロ、glibc の入った base では 12 件でした。
| イメージ | CVE 数 | 内訳 |
|---|---|---|
| static | 0 | — |
| base (+ glibc, OpenSSL) | 12 | HIGH 1, MEDIUM 3, LOW 8 |
| cc (+ libstdc++) | 12 | base と同数 |
Go (CGO_ENABLED=0) や Rust のように static で済む言語なら、この恩恵をそのまま受けられるのが魅力です。
Google Distroless 以外の選択肢
この記事では Google Distroless を中心に見てきましたが、 「最小限のコンテナイメージ」を目指すプロジェクトは他にも存在するので一部簡単に紹介します。
Docker Hardened Images (DHI)
Docker Hardened Images は、Docker Hub の公式イメージ (nginx, python, node 等) をシェル除去・non-root 化したハードニング版です。2025 年 12 月に Apache 2.0 ライセンスで無料化されました。
Google Distroless が「最小限から積み上げる」アプローチなのに対し、DHI は「既存イメージから削ぎ落とす」アプローチという違いがあります。このあたりも中身の違いを見てみると面白そうです。
Chainguard Images
Chainguard は、Google Distroless プロジェクトの元メンテナーが設立した企業です。独自の Linux ディストリビューション上にイメージを構築し、パッチの即時適用を実現しています。 *5
まとめ
今回は distroless コンテナイメージの中を覗いてみました。bazelでビルドされているためビルドプロセスを理解するにはbazelを理解する必要がありますが、実現していること、イメージの中身はシンプルなことがわかります。
必要な依存が足りないなどそのままでは導入できない場面もありますが、中身が見えてくると挑戦のハードルも下がるのではないでしょうか。興味が湧いた方は docker export や :debug タグ版で中を覗いてみてください。
We are hiring!
エムスリーではセキュリティやDevOpsに興味のあるエンジニアを大募集しています!興味のある方はぜひカジュアル面談にお越しください!
エンジニア採用ページはこちら
カジュアル面談もお気軽にどうぞ
インターンも常時募集しています
*1:実際にはコンテナとして起動時には /etc/hostname, /etc/hosts, /etc/resolv.conf 等が注入されます
*2:net パッケージはデフォルトで DNS 解決に libc の getaddrinfo を使うため
*3:distroless/static には ENTRYPOINT も CMD もないため、docker create にダミーコマンドを指定する必要がある
*4:Bazel の各 .bzl ファイルの oci_image() の base フィールドで確認できます
*5:https://edu.chainguard.dev/chainguard/chainguard-images/overview/