はじめに
先日のPHP2shellの対応で弊社のサービスでも緊急で対応をさせていただき、現在は特に被害もなくサービスを提供できています。
このような脆弱性の対応は新規開発のリソースを奪うことなるので、そもそも脆弱性を極力減らす仕組みを作るほうが良いと判断し、社内の複数サービスでコンテナのベースイメージを Docker Hardened Image(DHI) に切り替える対応を進めています。
「脆弱性が減る」「イメージが小さくなる」と言われますが、実際にどれくらいなのか、切り替え前後で実測しました。
この記事では、切り替え済みのサービス群と、これから切り替える予定のサービスについて、イメージサイズと trivy での脆弱性の件数を比べた結果をまとめます。リポジトリ名などの固有名詞は伏せています。
結論
-
runtime 版(
-devではない DHI)にすると、CRITICAL / HIGH がほぼ 0 件になる。Node の runtime は全件でも 32 件だった。 - サイズも小さくなる。元が Debian のフルイメージなら 3〜7 分の 1、slim でも約半分。
- ただし、最終ステージを
-devのままにすると、元が alpine のサービスでは、サイズも脆弱性も増える。 -
-devを外すには、最終ステージでシェルを使わない作りにする必要がある。
測り方
対象
- 切り替え済みのサービス: Node / Python / Go の 10 件前後
- 切り替え前のイメージと、DHI に切り替えた後のイメージを比較
サイズ
- 最終ステージのベースイメージ同士を比較。アプリの層は前後で同じなので、土台の差がそのまま最終イメージの差になる。
-
docker imagesの DISK USAGE(展開後)と CONTENT SIZE(圧縮後、レジストリ転送量の目安)の両方を記録。 - ローカル(arm64)で、実測日時点のタグを pull して測定。
脆弱性
-
trivy image --scanners vulnで、変更前後を同じ条件でスキャン。 - 件数は「CVE ID とパッケージの組」で重複を除いた数。
- 注意:
trivyは DHI が公開している VEX(影響しない CVE の除外情報)を適用していない。DHI 側は実際より多めに出ている可能性がある。
結果: イメージサイズ
最終ステージのベースイメージを比べたものです(ディスク上のサイズ)。
| 切り替え内容 | 変更前 | 変更後 | 差 |
|---|---|---|---|
| Node(slim)→ DHI runtime | 349〜356MB | 188MB | −46〜47% |
Node(フル)→ DHI -dev
|
1.64GB | 433MB | −74% |
Python(フル)→ DHI -dev
|
1.62GB | 220MB | −86% |
Go(フル)→ DHI -dev
|
1.25〜1.32GB | 822MB | −34〜38% |
| Node(alpine)→ DHI runtime | 239MB | 188MB | −21% |
Node(alpine)→ DHI -dev
|
235〜239MB | 432MB | +81〜84% |
alpine(13.6MB)→ DHI debian-base -dev
|
13.6MB | 117MB | +760% |
圧縮サイズ(レジストリ転送量)でも傾向は同じで、たとえば Node の slim → runtime は 85MB → 42MB(約 −51%)でした。
Go の API 8 本を実際にビルドして比べた結果
Go のアプリは静的バイナリなので、最終ステージを Debian の slim から DHI の static に替えられます。8 本の API を、変更前後で実際にビルドして測りました。
| 項目 | 変更前 | 変更後 |
|---|---|---|
| ディスク上のサイズ | 164〜278MB | 24〜74MB |
| 圧縮サイズ | 36.6〜62.9MB | 5.9〜14.2MB |
ディスク上のサイズは 3〜7 分の 1 になりました。バイナリが同じなので、サイズの差は土台(Debian の slim と static)の差そのものです。
結果: 脆弱性の件数
Node / Python / Go
CRITICAL と HIGH の合計、全件の順に示します。
| 切り替え内容 | 変更前 | 変更後 |
|---|---|---|
| Node(slim)→ DHI runtime | 49〜62 / 183〜250 | 0 / 32 |
| Node(slim)→ DHI runtime(別の slim 系列) | 66〜102 / 253〜345 | 0 / 32 |
Node(フル)→ DHI -dev
|
485 / 4,376 | 36 / 242 |
Python(フル)→ DHI -dev
|
471 / 3,871 | 98 / 1,132 |
Go(フル)→ DHI -dev
|
153〜285 / 2,056〜3,147 | 100 / 1,250 |
Node(alpine)→ DHI -dev
|
6〜9 / 19〜22 | 36 / 242 |
Go の API 8 本(実ビルド、debian:trixie-slim → DHI static)
| 項目 | 変更前 | 変更後 |
|---|---|---|
| CRITICAL / HIGH / MEDIUM / LOW | 0 / 54 / 68 / 63(1 本だけ 0 / 58 / 91 / 131) | 0 / 11 / 8 / 2 |
| 全件 | 185(1 本だけ 280) | 21 |
変更後に残る 21 件は、OS ではなく Go のバイナリ自体(標準ライブラリや依存モジュール)が原因です。変更前の 185 件にも同じものが含まれていて、DHI の static 側は 0 件でした。これは DHI 化では減らず、Go のバージョンや依存の更新が必要です。
-dev を外した場合
-dev と runtime の違いは大きく、-dev はシェル、パッケージマネージャ、ビルド用のツールが入っています。最終ステージを runtime にした場合の件数は次のとおりです。
| 対象 | 変更前 |
-dev(現状) |
-dev を外した場合 |
|---|---|---|---|
| Node(slim) | 63 / 250 | 36 / 242 | 0 / 32 |
| Node(alpine) | 6 / 19 | 36 / 242 | 0 / 32 |
| Node(フル) | 485 / 4,376 | 36 / 242 | 0 / 32 |
| Python | 471 / 3,871 | 98 / 1,132 | 8 / 54 |
| Go | 153〜285 / 2,056〜3,147 | 100 / 1,250 | 3 / 45 |
| alpine(13.6MB) | 23 / 64 | 33 / 146 | 6 / 56 |
- Node の runtime は、どのサービスでも 0 / 32 でした。
-devの 36 件の HIGH は、シェルやビルド用ツールが原因でした。 - 元が alpine でも、
-devを外せば変更前より減ります。 - Go の runtime(
golang:*-debian13)は 297〜307MB あり、Go の実行環境が入ったイメージです。
ハマりどころ
1. 最終ステージを -dev のままにすると、効果が出ない
alpine などもともと小さいイメージから -dev に替えると、サイズも脆弱性も増えます。さらに、本番のコンテナにシェルが残るので、DHI の「シェルなし・非 root」という利点も出ません。
2. runtime にするとシェルがなくなる
runtime にすると、最終ステージで次のものが使えなくなります。
-
RUN(mkdir、chmod、ln -sfなど) - シェル形式の
CMD/ENTRYPOINT - シェル形式の
HEALTHCHECK -
npmやpnpm(実行時にpnpm startを呼ぶ構成)
置き換え方の例です。
-
npm ci --omit=devやpnpm install --prodは、ビルド用のステージで実行して、node_modulesを最終ステージにCOPYする。 - 起動は
CMD ["pnpm", "start"]ではなくCMD ["node", "node_modules/.../bin/..."]のように、node を直接呼ぶ。 -
mkdir -pはWORKDIRで代用する。chmod +xはCOPY --chmod=755にする。 - 非 root がデフォルトなので、
useraddなどのユーザー作成は不要。COPY --chown=node:nodeで所有者を合わせる。
3. ローカル開発用の Dockerfile は -dev のまま
apt-get、go install、ホットリロード用のツールを使うローカル開発用の Dockerfile は、runtime にできません。本番用と分けて考えるのが現実的です。
4. ビルドで外部ダウンロードが失敗する(TLS インターセプト)
社内ネットワークで TLS が傍受されていると、docker build の中の go mod download や npm install が、証明書エラーで失敗します。ビルド用ステージにだけ CA 証明書を足して回避しました。最終イメージには入りません。
5. サイズの見方に注意
新しい docker images は、DISK USAGE(展開後)と CONTENT SIZE(圧縮後)の 2 つを出します。同じ土台でも数字が 4 倍ほど違うので、どちらで比較したかを揃える必要があります。
注意点
- 脆弱性の件数は、土台のイメージ(と Go バイナリ)だけを見ています。アプリの依存パッケージ(
node_modulesなど)の脆弱性は含みません。 - 件数は実測日時点の
trivyの DB のものです。日が経つと変わります。 -
trivyは DHI の VEX を適用していないので、DHI 側の件数は実際より多い可能性があります。 - 元のイメージは、切り替え当時のタグではなく、実測日時点のタグの内容です。
- サイズはローカル(arm64)での値です。amd64 では少し変わります。
まとめ
- DHI の runtime にすれば、土台の CRITICAL / HIGH はほぼ 0 件になり、サイズも小さくなる。
- 元が Debian のフルイメージや slim のサービスは、
-devでも効果が大きい。 - 元が alpine のような小さいイメージのサービスは、最終ステージで runtime を使わないと、むしろ増える。
- runtime を使うには、最終ステージのシェル依存(
RUN、pnpm startなど)をなくす必要がある。まずは、最終ステージで何を使っているかを洗い出すのが近道。 - Go は static にできれば、サイズも脆弱性も劇的に減る。残るのは Go のバイナリ側の脆弱性なので、Go のバージョンや依存の更新で対応する。