メインコンテンツへスキップ

ChainguardイメージでゼロCVEのDockerコンテナを作る

kiitosu
著者
kiitosu
aws community builder. 画像処理やデバイスドライバ、データ基盤構築からWebバックエンドまで、多様な領域に携わってきました。地図解析や地図アプリケーションの仕組みにも経験があり、幅広い技術を活かした開発に取り組んでいます。休日は草野球とランニングを楽しんでいます。
目次

はじめに
#

node:slimnode:alpine を本番で使っていると、CVEスキャンで毎週のように脆弱性が積み上がっていきます。trivy を CI に仕込んだ瞬間にダッシュボードが真っ赤になる、というのは Node コンテナを運用したことがある人なら一度は通る道だと思います。

ノイズに耐えかねて辿り着くのが Chainguard の 「ゼロCVE」イメージ です。今回は本当に CVE が消えるのか、サイズはどうなるのか、運用上の制約は何かを実機で確かめました。

結論を先に書きます:

  • CVE は完全にゼロ(trivy で CRITICAL/HIGH/MEDIUM/LOW/UNKNOWN 全レベル 0件を確認)
  • サイズは「軽い」とは言えない(公式 node:slim より大きい)
  • ⚠️ 無料公開タグは :latest のみでバージョン固定不可、有料サブスクリプション必須
  • ⚠️ ENTRYPOINT=node 固定で従来のDockerデバッグ手法が通用しない

期待していた像と実像はかなりずれていました。以下、実測ベースで詳細を整理していきます。

検証環境
#

サンプルアプリ
#

最小の TypeScript + Express アプリで、//health を返すだけのものを用意しました。

chainguard-test/
├── src/
│   ├── package.json   (express 4.21 + @types/* + typescript)
│   ├── tsconfig.json
│   └── server.ts
├── Dockerfile.latest      (node:25)
├── Dockerfile.slim        (node:25-slim)
├── Dockerfile.alpine      (node:25-alpine)
└── Dockerfile.chainguard  (cgr.dev/chainguard/node:latest)

Dockerfile(公平比較のため可能な限り共通化)
#

4つの Dockerfile はほぼ同一構造で、FROM 行だけが異なります。

FROM node:25-slim AS builder    # ここだけ各イメージで差し替え
WORKDIR /app
COPY src ./
RUN npm install && npm run build && npm prune --omit=dev

FROM node:25-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]

Chainguard版だけ2点違いがあります(後述)。

FROM cgr.dev/chainguard/node:latest-dev AS builder    # builderは -dev タグ
...
FROM cgr.dev/chainguard/node:latest                   # runtimeは無印
...
CMD ["dist/server.js"]                                # node を書かない

計測結果
#

イメージサイズ
#

$ docker images cg-test --format "table {{.Tag}}\t{{.Size}}"
TAG          SIZE
latest       1.14GB
slim         255MB
alpine       172MB
chainguard   422MB

期待を裏切る結果でした。alpine が最小で、Chainguardは slim よりも大きくなっています。

中身を du で確認すると、Chainguard が大きい原因は明確です。

$ docker run --rm --entrypoint sh cgr.dev/chainguard/node:latest \
    -c "du -sh /usr/bin/node /usr/lib/node_modules"
82.9M    /usr/bin/node
300.2M   /usr/lib/node_modules

Node.js本体で約83MB、bundleされたnpm関連で約300MB。これにWolfi(glibc)ベースのユーティリティを足すと、ベースイメージを起動した瞬間に419MBあります

「distroless だから小さい」という素朴な期待は裏切られます。Chainguard の謳い文句は 「最小」ではなく「ゼロCVE」 である、という前提で読む必要があります。

CVE件数(trivy実測)
#

これが今回の本命です。

trivy image --format json cg-test:<tag>

全 Severity でスキャンした結果は以下の通りです。

イメージ CRITICAL HIGH MEDIUM LOW UNKNOWN 合計
node:25 20 340 982 1025 69 2,436
node:25-slim 1 9 45 78 0 133
node:25-alpine 0 1 2 0 0 3
cgr.dev/chainguard/node:latest 0 0 0 0 0 0

Chainguardは 本当にゼロ で、LOW や UNKNOWN まで含めても1件も出てきません。

注目点は3つあります。

  1. node:25 は2,436件。シェル・パッケージマネージャ・各種ライブラリ起因の脆弱性が大量に乗っており、本番で使う選択肢ではありません
  2. alpine のスコアは健闘しています。サイズも最小、CVE も HIGH が1件のみ。LOW や UNKNOWN が空なのは、不要パッケージが少ないので脆弱性検出対象自体が少ないことを意味します
  3. Chainguardは全 Severity で完全にゼロ。Wolfi のローリングリリースモデル(毎日リビルド)と、必要最小限のパッケージしか入れない設計の組み合わせの成果です

「ゼロCVE」を主張するベンダーは多いですが、自前のサンプルアプリを乗せた状態で全Severity 実測してもゼロなのは素直に信頼できます。

ビルド時間
#

CIランナーで base image を初めて pull するシナリオを想定して、docker pull 込みの初回ビルド時間を計測しました。

イメージ 初回ビルド時間
node:20 35.2秒
node:20-slim 13.6秒
node:20-alpine 11.9秒
cgr.dev/chainguard/node:latest 30.8秒

alpine が圧倒的に速いです。node:20 と Chainguard はベースイメージのサイズ(〜400MB〜1GB級)の pull に時間を取られて30秒前後かかります。

CIで毎回キャッシュが消える環境(GitHub Actionsのpublic runner、軽量Kubernetes Job等)では、ここがビルド時間の支配項になります。Chainguard移行はCI高速化には寄与しない点に注意が必要です。

ただし base image がキャッシュされた状態(典型的なローカル開発・self-hosted runner・layer cacheありCI)では、4種とも数秒に収束し有意な差は出ませんでした。

起動・動作確認
#

/health/ のレスポンスを4種すべてで確認しました。Node.js バージョンは v25.9.0 で揃えています(理由は後述)。

$ curl http://localhost:3000/
{"message":"hello from chainguard-test","node":"v25.9.0","platform":"linux"}

4種すべて期待通り起動・応答しました。

移行で実際に詰まったポイント
#

ここからは「使ってみたら想定と違った」という体験記です。記事として一番価値があるのはここだと思っています。

落とし穴1: :latest タグでNodeバージョンが固定できない
#

最初の Dockerfile はあまり深く考えずに、node公式イメージは node:20 系、Chainguardは公式ドキュメントの例に倣って cgr.dev/chainguard/node:latest で組んでいました。

ビルドが通って / を叩いてみると、レスポンスを見てやっと気づきました。

$ curl http://localhost:3000/
{"node":"v25.9.0",...}    # chainguard
{"node":"v20.20.2",...}   # 他3つ

cgr.dev/chainguard/node:latest は最新の安定版を追従するタグで、現時点では Node 25 が入っていました。バージョンが揃っていないので公平比較になっていません。

Node を揃えようと cgr.dev/chainguard/node:20 を pull すると、

Error response from daemon: unknown: {"errors":[{"code":"MANIFEST_UNKNOWN",...}]}

バージョン固定タグは公開されていません。Chainguard のドキュメントを読むと:

Chainguard Images customers who subscribe to Node, Python and Java Images already have version tags for node:20

つまり :20 のような固定バージョンタグは有料サブスクリプション専用ということです。無料公開されているのは :latest:latest-dev だけで、Nodeのバージョンを固定したければ課金が必要になります。

これは実運用では大きな制約になります。本番環境で :latest を採用すると、ある日突然 Node 26 にメジャーアップグレードされて再現性を失います。Chainguard 移行を本気で検討する場合、ライセンス費用の試算は避けて通れません。

本記事ではChainguard側を Node 20 に下げられないので、逆に 他3つを Node 25 系に上げて揃え直し、全イメージを Node v25.9.0 で再測定しました。

落とし穴2: ENTRYPOINT=node で従来のデバッグコマンドが破綻する
#

shellの存在を確認しようとしたら、想定外のエラーで詰まりました。

$ docker run --rm cg-test:chainguard sh -c "ls /app"

Error: Cannot find module '/app/sh'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1475:15)
    ...
Node.js v25.9.0

sh が無いのではなく nodesh をJSモジュールとして解釈しているのです。

ChainguardのNodeイメージは ENTRYPOINT ["node"] がベースで設定されており、何も指定しないコマンドは全て node への引数として渡されます。

docker run cg-test:chainguard sh -c "..."
                ↓ 実体
node sh -c "..."
                ↓
node が "sh" をモジュールパスと解釈 → "/app/sh" を探してエラー

正しくは --entrypoint で上書きします。

docker run --rm --entrypoint sh cg-test:chainguard -c "true"

これは Chainguard 利用時の 必修知識です。普段の docker run image bash 感覚で叩くと意味不明なエラーで30分溶かします。

落とし穴3: 「distroless」の定義は揺らいでいる
#

調査前は「Chainguard = distroless = shell すら入っていない最小イメージ」と思っていました。しかし実際にコンテナの中を見ると意外な光景がありました。

$ docker run --rm --entrypoint sh cgr.dev/chainguard/node:latest \
    -c "which sh ls cat node"
/usr/bin/sh
/usr/bin/ls
/usr/bin/cat
/usr/bin/node

shlscat が入っています。bash は無いものの、busybox 的な軽量ユーティリティは揃っています。

そもそも 「distroless」という用語自体が業界で揺らいでいる ことに気づきました。原典の Google distroless(gcr.io/distroless)でも、:debug タグはbusybox shellを含んでいます。Chainguard も chainguard/static のような純粋なものから、shellや基本utilsを含む chainguard/node まで幅があります。

「distroless / not distroless」の二値ではなく、含まれる機能で整理するのが良さそうです。

区分 shell パッケージマネージャ 基本utils (ls/cat) libc
フルディストリ ✓ bash ✓ apt ubuntu, node:25
削減版 ✓ sh ✓ apk node:slim, node:alpine
near-distroless ✓ sh × ✓ 一部 cgr.dev/chainguard/node:latest
pure distroless × × × gcr.io/distroless/static
static-only × × × × scratch

この軸で見ると Chainguardのnode:latest は near-distroless に分類するのが正確です。「distroless」をマーケティング的に名乗ることはありますが、Googleの純粋distrolessとは別物です。

サイズが422MB ある理由もここに帰着します。glibc・基本ユーティリティ・Node.js本体・bundleされた npm を含むと、このくらいになります。

これはネガティブにも、ポジティブにも捉えられます。

  • ポジティブ: トラブル時に kubectl exec -it pod sh でログ確認・ファイル確認ができる
  • ネガティブ: 「pure distroless」を期待していると、shell残存と422MBサイズが意外に映る

導入を検討する際は 「distroless」ラベルではなく、上の表のどの段に該当するか で判断するのが筋が良さそうです。Chainguardの実体は 「near-distrolessでゼロCVE運用される最小Linux + Node.js」 です。

採用判断のフレーム
#

実測結果を踏まえて、Chainguard を採用すべき/すべきでない場面を整理します。

採用が向いている
#

シーン 理由
規制業界(金融・医療・公共) 「未対応CVEあり」が監査でNGになる現場では、ゼロCVEの説得力が圧倒的
CVEスキャン疲れ アラートが鳴りやまない → ノイズ排除のためのbase image切替
チームの保守工数を減らしたい base image bump の頻度を下げ、毎日のリビルドはChainguard側に任せる
コンプラ報告のしやすさ SBOM・署名・provenance情報が標準で同梱されており説明しやすい

採用が向かない
#

シーン 理由
個人開発・小規模OSS 有料サブスクが前提のバージョン固定が必要、コスト合わない
イメージサイズを最小化したい alpine の方が圧倒的に軽い (172MB vs 422MB)
特定Nodeバージョン固定が必須 :latest 追従しか公開されていない
CIスピード最重要 サイズが大きいので pull/転送に時間がかかる

node:alpine と Chainguard どちらを選ぶか
#

実は今回の検証で alpine は健闘 しています。

観点 node:25-alpine chainguard/node:latest
サイズ 172MB 422MB
CRITICAL 0 0
HIGH 1 0
MEDIUM 2 0
LOW 0 0
バージョン固定 可(無料) 不可(要サブスク)
libc musl glibc
パッチ運用 自己責任 毎日リビルド

HIGH 1件 + MEDIUM 2件が許容できる現場、または musl で動かせるアプリなら alpine は十分競争力があります。コストもサイズも勝ちます。

Chainguard が圧勝するのは「LOW までゼロでないと監査が通らない」「毎日のリビルドを保守したくない」という規制対応・運用負荷削減の局面に限られます。

最後に
#

「Chainguard でゼロCVEコンテナ」は確かに実現できます。trivy で測ってもゼロは本物でした。

ただし広告通りの「軽量・最小・distroless」を期待すると裏切られます。

  • サイズは公式 alpine の2倍以上
  • バージョン固定タグは有料
  • 完全な distroless ではなくshellも入っている
  • ENTRYPOINT=node の罠でdebug体験が変わる

本物の価値は「ゼロCVEを継続的に維持してくれる運用」にあります。CVEスキャンが業務クリティカルなパスになっている現場には強く効きますが、個人開発や小規模プロジェクトでは alpine で十分なケースが多そうです。

「とりあえず脆弱性ゼロ」という派手なフックに惑わされず、自分の現場でCVEスキャン疲れが本当のボトルネックになっているか を見極めてから採用判断するのが筋が良さそうです。

参考リンク
#

執筆補助として Claude を使用しています

Reply by Email

関連記事

Authenticatorアプリの仕組み — MFAの中のTOTPを自作する
CloudWatch Pipelinesを使ってみた
Lambda Durable Functionsを使ってみる — Step Functionsとの比較からハンズオンまで
· loading · loading