Certified Kubernetes Security Specialist (CKS) #13 最小イメージ: distroless、scratch (Supply Chain)
#12 Pod-to-Pod mTLS: Cilium までで Minimize Microservice Vulnerabilities ドメインを終えました。この記事から比重 20% の Supply Chain Security ドメインに入ります。サプライチェーンセキュリティはクラスターに載るイメージがどこから来て何を含んでいるのかを統制する仕事であり、その第一歩は イメージ自体を最小化すること です。含むものが少ないほど、攻撃できる場所も少ないからです。
この記事では、大きなイメージがなぜ危険なのか、distroless と scratch が何を削るのか、alpine と比べてどう選ぶのか、そしてマルチステージビルドでビルドツールをランタイムから切り離す標準パターンを Dockerfile の例で押さえます。
なぜイメージの最小化がサプライチェーンセキュリティの始まりなのか #
サプライチェーンセキュリティは「自分のクラスターで動くコードが正確に何なのか」を統制する仕事です。そのコードの出発点がコンテナイメージであり、イメージの中には自分のアプリケーションだけでなく、ベースイメージが引き連れてきた OS ライブラリ、パッケージマネージャ、シェル、各種ユーティリティが一緒に入っています。この付随的な構成要素こそが攻撃面です。
イメージを最小化するというのは、アプリケーションを動かすのに必ず必要なものだけを残し、残りをすべて削ること です。削った分だけ次の 3 つが減ります。
- CVE の数。パッケージが少なければ既知の脆弱性も少なくなります。ubuntu ベースのイメージを Trivy でスキャンすると、アプリケーションと無関係な OS パッケージから数十件の CVE が出ることがよくあります。
- 侵入後の行動半径。シェルとパッケージマネージャがなければ、攻撃者がコンテナに侵入しても追加のツールをダウンロードしたり対話型シェルを立ち上げたりするのが難しくなります。
- イメージサイズ。小さいイメージはデプロイが速く、転送中に改ざんされる可能性も減ります。
イメージの最小化が #14 Image scan や #15 イメージ署名 より先に来る理由がここにあります。スキャンする面と署名する対象そのものを小さくすることがサプライチェーンセキュリティの土台だからです。
大きなイメージが広げる攻撃面 #
full OS をベースに使うイメージには、アプリケーションが絶対に使わない構成要素がぎっしり入っています。代表的に次のものが危険です。
| 構成要素 | 攻撃者が悪用する方法 |
|---|---|
シェル (/bin/sh、/bin/bash) | 侵入後の対話型コマンド実行、スクリプトのダウンロード・実行 |
パッケージマネージャ (apt、apk、yum) | コンテナ内での追加攻撃ツールのインストール |
curl·wget | 外部からのペイロードのダウンロード、データの流出 |
コンパイラ・ビルドツール (gcc、make) | コンテナ内でのエクスプロイトのコンパイル |
| 不要な OS ライブラリ | 既知の CVE の蓄積、脆弱性の連鎖 (chain) |
核心は これらのツールがアプリケーションの実行には必要ない という点です。Go でコンパイルした静的バイナリは、シェルもパッケージマネージャもなしに単独で動きます。ところがベースイメージがこれらすべてを引き連れてくると、普段は使われないまま、侵入の時点でだけ攻撃者にツールとして活用されます。だから「使わないものはそもそも含めない」が最小化の原則です。
distroless、scratch、alpine の比較 #
イメージを最小化するベースの選択肢は大きく 3 つです。それぞれ何を含み何を削るのかを表で押さえます。
| 項目 | scratch | distroless | alpine |
|---|---|---|---|
| ベースの内容 | 完全に空のイメージ | libc・CA 証明書・tzdata などランタイムの最小構成のみ | musl libc + BusyBox ベースの最小 Linux |
| シェル | なし | なし (:debug タグを除く) | あり (/bin/sh) |
| パッケージマネージャ | なし | なし | あり (apk) |
| サイズ | 最も小さい (数 MB 以下も可能) | 小さい (数十 MB) | 小さい (約 5MB 台) |
| non-root デフォルト | 自分で設定 | :nonroot タグを提供 | 自分で設定 |
| 適したワークロード | 静的バイナリ (Go、Rust) | libc が必要なコンパイル・インタプリタ言語 | シェル・パッケージが必要な場合 |
| デバッグの難易度 | 高い (シェルなし) | 高い (:debug または ephemeral container) | 低い (シェルあり) |
選択の基準は単純です。アプリケーションが 静的バイナリ なら scratch が最も小さいです。libc や証明書、タイムゾーンのようなランタイム依存がある場合は、それだけを含む distroless が安全なデフォルトです。alpine はシェルとパッケージマネージャがあって扱いやすいですが、その分だけ攻撃面が distroless より広く、musl libc 特有の互換性問題が生じることがあるので、セキュリティの観点では distroless を優先します。
distroless イメージは Google が gcr.io/distroless/ のパスで言語別のバリアントを提供しています。static、base、cc、java、nodejs、python3 などがあり、それぞれ :nonroot と :debug タグも一緒に提供されます。
マルチステージビルドでビルドツールを除去する #
イメージを最小化するには、ビルドに必要なツールとランタイムに必要なものを分離 しなければなりません。コンパイラ、ビルド依存、ソースコードはビルド時点だけで必要で、ランタイムには成果物のバイナリだけあればよいです。この分離を 1 つの Dockerfile の中でやってくれるのが マルチステージビルド です。
原理は次のとおりです。
- build stage。コンパイラの入った重いベース (例:
golang) でソースをビルドしてバイナリを作ります。 - runtime stage。distroless や scratch のような最小ベースで、build stage が作ったバイナリだけを
COPY --fromで取り込みます。
最終イメージには最後の stage の内容だけが残ります。コンパイラもソースコードも中間生成物もすべて抜け、実行ファイルと最小ランタイムだけが残ります。
Go アプリケーション: build stage → distroless #
Go は静的バイナリを作れるので、最小化に最もよく合う言語です。
# build stage
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 静的リンクバイナリの生成 (CGO 無効)
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server
# runtime stage: distroless static, non-root
FROM gcr.io/distroless/static:nonroot
WORKDIR /app
COPY --from=build /app/server /app/server
USER nonroot:nonroot
ENTRYPOINT ["/app/server"]ここで核心は 2 つです。まず、CGO_ENABLED=0 で静的リンクバイナリを作り、libc 依存さえなくしました。だから最も小さい distroless/static を使えます。次に、最終イメージには golang ベースが引き連れてきたコンパイラとツールが 1 つも入りません。COPY --from=build でバイナリ 1 個だけを取り込んだからです。
静的バイナリなら scratch まで下げることもできます。
FROM scratch
COPY --from=build /app/server /server
# HTTPS 呼び出しが必要なら CA 証明書を直接コピー
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/server"]scratch は本当に空なので、CA 証明書やタイムゾーンファイルが必要なら build stage から直接コピーしなければなりません。この手間のため、実務では証明書をあらかじめ含んだ distroless/static をより頻繁に使います。
Node.js アプリケーション: build stage → distroless #
インタプリタ言語もマルチステージで依存インストールの段階をランタイムから分離します。
# build stage: 依存のインストール
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
# runtime stage: distroless nodejs, non-root
FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=build /app /app
USER nonroot
CMD ["server.js"]distroless/nodejs ベースには node ランタイムだけが入っていて、npm もシェルもありません。build stage で npm ci でインストールした node_modules とアプリケーションコードだけを取り込むので、最終イメージにパッケージマネージャが残りません。
non-root ユーザーで動かす #
イメージを最小化しながら必ず一緒に押さえるべきものが non-root 実行 です。コンテナが root で動くと、侵入時にコンテナ内でできることが多くなり、ノード権限への昇格につながる余地も大きくなります。
distroless は :nonroot タグを使うと UID 65532 の non-root ユーザーでデフォルト実行されます。scratch や一般ベースでは自分でユーザーを作って指定します。
FROM alpine:3.20
RUN addgroup -S app && adduser -S app -G app
USER app
ENTRYPOINT ["/app/server"]イメージレベルの USER 指定とともに、ワークロードレベルではイメージの USER を信頼せず、Pod の securityContext でもう一度釘を刺すのがよいです。
securityContext:
runAsNonRoot: true
runAsUser: 65532
allowPrivilegeEscalation: falserunAsNonRoot: true はイメージが root で実行されるよう設定されていれば Pod 自体の起動を拒否するので、最小イメージと対になる防御線です。securityContext 自体は #9 Pod Security Admission と CKAD #4 で扱った内容につながります。
シェルのないイメージをデバッグする方法 #
distroless と scratch の最大の実務上の不便は シェルがなくて kubectl exec で入れない という点です。しかしシェルをなくしたことがセキュリティの核心なので、デバッグのためにシェルを入れ直すのは本末転倒の選択です。2 つの定石があります。
1) ephemeral container #
運用中のコンテナに触れずに、デバッグツールの入った一時コンテナを同じ Pod に差し込む方法です。対象コンテナのプロセスネームスペースを共有するので、シェルのないコンテナのファイルシステムとプロセスを覗き込めます。
kubectl debug -it mypod \
--image=busybox:1.36 \
--target=app \
--share-processesこの方式は運用イメージにシェルを追加せずにデバッグを可能にするので、CKS と CKA の両方で推奨される定石です。ephemeral container の詳細な動作は CKA トラックのトラブルシューティング編で扱った内容と同じです。
2) distroless :debug タグ
#
Google distroless は BusyBox シェルを含んだ :debug タグを別途提供します。開発・デバッグの段階でだけこのタグでビルドしてシェルを使い、運用デプロイにはシェルのない通常タグを使う形で分離します。
# デバッグビルドでのみ使用
FROM gcr.io/distroless/static:debug運用イメージには絶対に :debug を残さないのが原則です。シェルを残した瞬間に最小化の利点が消えるからです。
試験の定番: Dockerfile を直す #
CKS の Supply Chain Security ドメインでよく出る作業は 与えられた Dockerfile をより安全に直すこと です。典型的な出題は次のとおりです。
次の Dockerfile は単一 stage でビルドされ、大きなベースイメージを使い、root で実行されます。マルチステージビルドに変え、distroless ベースを適用し、non-root で実行されるよう修正しなさい。
修正前の Dockerfile はたいていこんな形です。
FROM golang:1.22
WORKDIR /src
COPY . .
RUN go build -o /server ./cmd/server
ENTRYPOINT ["/server"]この場合、採点が見るポイントは明確です。
- マルチステージに分離 したか。
FROM ... AS buildと 2 つ目のFROMがあるか。 - 最小ベースに置き換え たか。最終 stage が distroless か scratch か。
COPY --fromでバイナリだけを取り込んだか。ソースやコンパイラが最終イメージに残っていないか。- non-root で実行 されるか。
:nonrootタグやUSER指定があるか。
修正後は先に見た Go の例と同じ形になります。試験会場では、ビルドが実際に成功しコンテナが正常に起動するかまで確認してこそ、部分点ではなく満点を取れます。CGO_ENABLED=0 を抜かして静的リンクされていないまま scratch に載せると、バイナリが libc を探しに行って起動に失敗するので、ベースとビルドオプションの対を合わせることが落とし穴です。
試験ポイント #
- イメージの最小化 = 攻撃面の縮小。シェル・パッケージマネージャ・不要な OS ライブラリを削れば、CVE の数と侵入後の行動半径が一緒に減ります。
- scratch は空のベース、distroless はランタイムの最小構成のみ。静的バイナリは scratch、libc・証明書が必要なら distroless がデフォルトです。alpine はシェル・
apkがあって楽ですが、攻撃面がより広いです。 - マルチステージビルドが核心技術。build stage でコンパイルし、runtime stage に
COPY --fromでバイナリだけを取り込めば、コンパイラとソースが最終イメージから抜けます。 - non-root の対合わせ。distroless
:nonrootタグやUSER指定に加え、Pod のsecurityContextでrunAsNonRoot: trueを釘付けにします。 - シェルのないイメージのデバッグ。運用イメージにシェルを入れず、
kubectl debugの ephemeral container または distroless:debugタグを使います。 - 試験の定番は Dockerfile のリファクタリング。マルチステージ化 + 最小ベースの置き換え + non-root 適用を一度に要求し、ビルド成功と起動確認まで終えてこそ満点です。
まとめ #
サプライチェーンセキュリティはクラスターに載るイメージを統制する仕事であり、その出発点はイメージを最小化して攻撃面そのものを減らすことです。大きなイメージが引き連れてくるシェルとパッケージマネージャは、アプリケーションの実行には役に立たないまま、侵入の時点でだけ攻撃者にツールになります。distroless と scratch はこの付随要素を削った最小ベースであり、マルチステージビルドでビルドツールをランタイムから切り離せば、コンパイラとソースなしに成果物だけを含んだ小さいイメージを作れます。ここに non-root 実行と ephemeral container デバッグを対にすれば、小さくても運用可能なイメージになります。
次へ: Image scan #
イメージを小さくしたら、次はその中に残ったものが安全かを確認する番です。いくら最小化しても、ベースライブラリやアプリケーションの依存に既知の脆弱性が入っていることがあるからです。
#14 Image scan: Trivy、Kubesec、KubeLinter では、イメージの CVE を Trivy でスキャンする方法、スキャン結果を深刻度で絞ってゲートにかける方法、そしてマニフェストのセキュリティ設定を Kubesec と KubeLinter で静的点検するパターンまで、直接動かしながら整理します。