コンテナのセキュリティを高めるDHI - node/python/ruby/goでの比較
はじめに
Dockerが提供するDocker Hardened Images(DHI)は、最小構成かつ署名付きの本番向けベースイメージです。FROMを置き換えるだけで使えるよう設計されている一方、ランタイムイメージにはshellもパッケージマネージャも入っていないので、既存のDockerfileをそのままコピーしてもうまくいかない箇所が出てきます。
この記事ではnode 24、python 3.11、Rails 8 (Ruby 3.4)、golang 1.25の4言語について、DHI採用前と採用後のDockerfileを並べ、docker buildからcurlでの応答確認までをローカルで動かした結果をまとめます。比較対象はDHIのCommunityサブスクリプションで公開されているdhi.io配下のイメージです。
この記事の焦点と読み方
この記事は次の3点だけに焦点を絞っています。
- 既存のDockerfileをDHIへ移行するときに、
FROMの差し替え以外で何を直すか - 4言語のbefore/afterを並べて、サイズと構造の差を比較できる形にすること
- 自分のローカルで実際にビルドと起動まで通したうえで、引っ掛かった箇所を明示して戸惑いやすい箇所を明示すること
逆に、次のテーマには深入りしません。
- Helmチャートやシステムパッケージの利用
-
docker scoutの詳細な脆弱性スコア比較 - Kubernetes側のSecurityContext設計
- Selectサブスクリプション以上で使える社内ミラーリングや独自バリアントの構築
これらは公式のDocker Hardened Imagesガイドが章立てごとに整理しているので、必要に応じて本記事と読みあわせてください。
対象読者
すでにDockerfileを業務で書いていて、マルチステージビルドと非rootコンテナの基本概念は知っている層を想定しています。「ここでつまずいたら次はこの公式ページ」というインラインリンクを残しているので、必要に応じて公式リンクをご参照ください。
なお、DHIにはCommunity、Select、Enterpriseの三つのサブスクリプションがあり、SLA付きのCVEパッチやFIPS/STIGバリアントの利用、社内ネームスペースへのミラーリングなどはSelect以上の契約に紐付いています。Community版でもSBOMとSLSA Build L3 provenanceは付属するので、攻撃面の最小化と来歴検証だけが目的なら無料サブスクリプションでも十分に試せます。
何が一番変わるか
DHI移行で最も大きく変わるのは、ビルドとランタイムを分けて、それぞれ別のイメージからFROMする点です。図にすると、Dockerfileの責務が次のように2段に切り分けられます。
ビルドステージにはshellとパッケージマネージャが揃った-devバリアントを置き、ランタイムステージにはそれらを含まない最小バリアントを置きます。最小の差分は次のようになります。
- FROM node:24-alpine
+ FROM dhi.io/node:24-alpine3.21-dev AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
RUN npm run build
+ FROM dhi.io/node:24-alpine3.21
+ WORKDIR /app
+ COPY --from=builder --chown=node:node /app /app
+ USER node
CMD ["node", "dist/index.js"]
ビルドに必要なapk/apt/npm install/pip install/bundle install/go buildはすべて-devサフィックスのついたビルド用バリアントで実行し、最終ステージはshellもパッケージマネージャも持たないランタイム用バリアントだけにする、というのがDHIの基本形です。Dockerのマルチステージビルドに慣れていれば違和感は少ないはずです。
なおdhi.ioからpullするにはDockerアカウントでの認証が必要で、最初にdocker login dhi.ioを一度だけ実行します。CIでは同コマンドをパイプラインの先頭に追加します。
検証環境
筆者のローカル環境はmacOS(Tahoe 26.4.1)上のDocker 29.4.1です。サンプルアプリケーションはdhi-migrationに置いてあります。各アプリはそれぞれ8080番ポートで{"runtime":"...","version":"...","ok":true}を返すだけのHTTPサーバを動かしました。
各イメージ作成前にdocker login dhi.ioで認証だけ済ませてあります。サブスクリプションはCommunityです。
node24でのDHI採用前後比較
採用前は単一ステージでnpm installからnpm run buildまでを同じイメージで完結させていました。
# 従来のDockerfile(Node.js 24)
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev || true
COPY . .
RUN npm run build
EXPOSE 8080
CMD ["node", "dist/index.js"]
採用後はビルド専用ステージとランタイムステージに分けます。Docker公式のNode.js移行例でも、最終ステージはUSER nodeで実行する形が示されています。
# DHI移行後(Node.js 24)
# syntax=docker/dockerfile:1
FROM dhi.io/node:24-alpine3.21-dev AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --omit=dev || true
COPY . .
RUN npm run build
FROM dhi.io/node:24-alpine3.21
WORKDIR /app
COPY --from=builder --chown=node:node /usr/src/app/package*.json ./
COPY --from=builder --chown=node:node /usr/src/app/dist ./dist
USER node
EXPOSE 8080
CMD ["node", "dist/index.js"]
docker buildした結果のイメージサイズはalpineで比較したところ、229MBから172MBと小さくなっています。単にサイズだけでの比較ならalpineと拮抗する範囲内ですが、DHIにはイメージごとにVerifiable SBOMとSLSA Build L3 provenanceが紐付いている点が大きな差分になります。
dhi-demo-node-before 229MB
dhi-demo-node-after 172MB
なおnpm install時にnative addonをビルドする場合はpython3 make g++などをbuilderステージでapk addしておく必要があります。runtimeステージではapkそのものが使えないので、ここを勘違いするとビルドが通らなくなります。
python3.11でのDHI採用前後比較
採用前はpython:3.11-slimをそのまま使い、pip installもruntimeステージで実行していました。
# 従来のDockerfile(Python 3.11)
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["python", "main.py"]
採用後はPython移行例に従い、-devバリアントで仮想環境を作って依存をそこに入れ、ランタイムステージへ/venvごとコピーします。USER nonrootが前提なので、COPY --chown=nonroot:nonrootで所有権を明示しておくと書き込み先での権限エラーを避けられます。
# DHI移行後(Python 3.11)
# syntax=docker/dockerfile:1
FROM dhi.io/python:3.11-debian12-dev AS builder
WORKDIR /app
COPY requirements.txt .
RUN python -m venv /venv \
&& /venv/bin/pip install --no-cache-dir -r requirements.txt
COPY . .
FROM dhi.io/python:3.11-debian12
WORKDIR /app
COPY --from=builder /venv /venv
COPY --from=builder --chown=nonroot:nonroot /app /app
ENV PATH="/venv/bin:$PATH"
USER nonroot
EXPOSE 8080
CMD ["python", "main.py"]
サイズは228MBから154MBに縮小しました。
dhi-demo-python-before 228MB
dhi-demo-python-after 154MB
psycopg2、Pillow、numpyのようにC拡張を持つ依存があるなら、builderとruntimeでlibcの種類(glibc系のdebianかmusl系のalpineか)を必ず合わせます。Docker公式も互換性の観点から同じディストリビューション系統に寄せることを推奨しています。
ruby3.4 + Rails8でのDHI採用前後比較
採用前は単一ステージでruby:3.4-slimの上にbundle installもアプリ起動もまとめて任せていました。build-essentialとlibyaml-devはRails 8系の依存(psychなど)を素直にビルドさせるために入れています。
# 従来のDockerfile(Rails 8 / Ruby 3.4)
FROM ruby:3.4-slim
WORKDIR /app
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
build-essential libyaml-dev \
&& rm -rf /var/lib/apt/lists/*
COPY Gemfile Gemfile.lock ./
RUN bundle install --without development test
COPY . .
ENV RAILS_ENV=production \
RAILS_LOG_TO_STDOUT=1 \
PORT=8080
EXPOSE 8080
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
採用後はRuby移行ガイドに沿い、-devバリアントでbundle installを済ませ、ランタイムはdistrolessなdhi.io/ruby:3.4-debian12に切り替えます。Rails特有の引っ掛かり箇所が複数あるため、DockerfileそのものはPython版とよく似ていますが、起動コマンドには少し工夫が要ります。
# DHI移行後 (Rails 8 / Ruby 3.4)
# syntax=docker/dockerfile:1
FROM dhi.io/ruby:3.4-debian12-dev AS builder
WORKDIR /app
ENV BUNDLE_PATH=/app/vendor/bundle \
BUNDLE_DEPLOYMENT=1 \
BUNDLE_WITHOUT="development:test"
COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs 4
COPY . .
FROM dhi.io/ruby:3.4-debian12
WORKDIR /app
ENV RAILS_ENV=production \
RAILS_LOG_TO_STDOUT=1 \
PORT=8080 \
BUNDLE_PATH=/app/vendor/bundle \
BUNDLE_DEPLOYMENT=1 \
BUNDLE_WITHOUT="development:test" \
PATH="/app/vendor/bundle/ruby/3.4.0/bin:${PATH}"
COPY --from=builder --chown=nonroot:nonroot /app /app
USER nonroot
EXPOSE 8080
# DHI runtimeはshellも`bundle`バイナリも持たないdistrolessなので
# exec形式でrubyに小さなランチャを直接渡す。
CMD ["ruby", "bin/start.rb"]
bin/start.rbは次の数行だけのファイルで、bundle exec puma ...の代わりにPumaを直接ブートします。
#!/usr/bin/env ruby
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup"
require "puma/cli"
Puma::CLI.new(["-C", "config/puma.rb"]).run
サイズは827MBから284MBに縮小しました。Rails本体だけでもかなり大きいので、distrolessランタイムへ移行したときの絶対量・割合ともに今回の検証では一番大きい削減になっています。
dhi-rails-before 827MB
dhi-rails-after 284MB
Railsをdistrolessに持っていくときに筆者がはまった具体ポイントは次の三つです。
- DHI runtimeのRubyイメージはshellも
bundleも持たないdistrolessで、CMD ["bundle", "exec", "puma", ...]をそのまま書くとexecutable file not found in $PATHで起動に失敗します。前述のbin/start.rbのような数行のRubyランチャを用意し、CMD ["ruby", "bin/start.rb"]で起動させるのが安全です。 -
BUNDLE_DEPLOYMENT=1を有効にするとbundle installがGemfile.lock必須になります。rails new直後の状態だとlockfileを.gitignoreの対象にしてしまうケースもあるので、Dockerfileから参照する以上は必ずコミット対象にしておきます。bundle lock --add-platform aarch64-linux --add-platform x86_64-linuxのように対象アーキを明示しておくと、Apple SiliconとLinux x86_64のどちらの環境でも同じlockfileからビルドできます。 - distrolessランタイムにはシステムのtzdataが入っていないため、Railsの起動時にActiveSupportが
TZInfo::DataSourceNotFoundを投げて落ちます。Gemfileにgem "tzinfo-data"を1行追記しておくと、純粋にRuby側で時刻データを解決できるのでdistrolessでもそのまま起動します。
細かい話ですが、config/puma.rbでportとbindを両方書いてしまうと8080番に2重bindしてAddress already in use (Errno::EADDRINUSE)が出ます。Rails8系のPuma6ではbind "tcp://0.0.0.0:#{ENV.fetch('PORT', 8080)}"だけ書いてport行は削ってしまう対応をおすすめします。
なおdhi.io/ruby:3.4-debian12-devとdhi.io/ruby:3.4-debian12ではRubyのパッチバージョンが揃っていないことがあります(手元ではbuilderが3.4.9、runtimeが3.4.5でした)。アプリ側でRUBY_VERSIONに厳密一致を強制している場合は、Gemfileのruby "~> 3.4"のように許容幅を取っておく方が安全です。
go1.25でのDHI採用前後比較
採用前はgolang:1.25の上で直接go buildしていました。
# 従来のDockerfile(Go 1.25)
FROM golang:1.25
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
EXPOSE 8080
CMD ["/app/server"]
DHI採用後はGo移行例に従い、dhi.io/golang:1.25-devでビルドし、最終ステージにはバイナリ実行に特化したdhi.io/staticを置くのが推奨パターンです。
# DHI移行後(Go 1.25)
# syntax=docker/dockerfile:1
FROM dhi.io/golang:1.25-alpine3.21-dev AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
FROM dhi.io/static:latest
WORKDIR /app
COPY --from=builder /app/server /app/server
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/app/server"]
dhi.io/staticはca-certificates、tzdata、nonroot userだけを持つ最小ランタイムで、Goのstaticバイナリを置くのに最適です。Alpineベース、Debianベース、それぞれにmuslやglibcを含む派生バリアントが用意されているので、CGO有効でビルドした場合は対応するライブラリ入りのバリアントを選びます。
ただしdhi.io/staticはCommunityサブスクリプションのカタログからdocker pullできるタグが筆者の環境では確認できず、latest、debian-12、alpine-3.22などをいずれもpullできませんでした。具体タグはカタログのImagesタブで都度確認するのが確実です。検証では代替として最終ステージにdhi.io/golang:1.25(non-devランタイム)を置き、USER nonrootで起動して動作確認しました。
# 検証用に最終ステージをgolang non-devランタイムへ置き換えたもの
# syntax=docker/dockerfile:1
FROM dhi.io/golang:1.25-alpine3.21-dev AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server
FROM dhi.io/golang:1.25
WORKDIR /app
COPY --from=builder /app/server /app/server
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/app/server"]
サイズは1.37GBから377MBになりました。dhi.io/staticを最終ステージに使えれば数MB台まで落ちる想定なので、本番運用ではタグの確定を優先したいところです。
dhi-demo-go-before 1.37GB
dhi-demo-go-after 377MB
いつDHIを選ぶか、選ばないか
採用判断のとき筆者が見ている観点を一表にまとめます。サイズだけを軸にしていないところがポイントです。
| 観点 | DHIが向くケース | 別の選択肢が向いているケース |
|---|---|---|
| CVE件数 | リリースまでに既知CVEを下げたい | 内部ネットワーク限定でCVEが許容できる |
| 来歴検証 | SBOMやSLSA provenanceの要求がある | 検証要件がそもそもない |
| 攻撃面 | コンテナ侵入後のlateral movementを抑えたい | 使い捨てツール用で攻撃面を気にしない |
| デバッグ運用 | Docker Debugやサイドカーへの切り替えに合意できる |
docker exec ... sh前提を捨てられない |
| 言語 | Goのようにstaticバイナリで済む、Pythonのように仮想環境で完結する | OSパッケージを実行時に動的に追加したい |
| サブスクリプション | 無料枠で十分、もしくはSelect/Enterpriseの契約予算がある | 商用サポートやFIPS変種が要るが予算がない |
判断に迷ったら、まずはCommunityで検証用のリポジトリだけ移行してみて、docker scout compareで件数差を取ってから本格移行を決めると失敗は減ります。
実際に動かすときの確認項目
筆者は次の順で書き換えと検証を進めました。チェックリストとしても使えます。
- 既存
FROMの素性を確認します。Debian系か、Alpine系か、Ubuntuベースか、を見ておきます。 -
DHIカタログで同系統のイメージを探し、
-devタグと非-devタグの両方を控えます。 - Dockerfileをマルチステージ構成に直し、builderを
-dev、runtimeを非-devにします。 -
RUN apt、RUN apk、RUN pip install、RUN npm install、RUN bundle exec、RUN go buildはすべてbuilderステージへ移します。 - 最終ステージは成果物だけを
COPY --chownで運び込み、書き込みディレクトリの権限とlistenポートを見直します。Kubernetes利用や古いDocker Engineでは非rootユーザーが1024未満のポートを開けないので、コンテナ内では8080などを使います。 -
docker exec ... shに依存した運用になっていないかを点検します。runtimeイメージにはshellがないので、必要ならDocker Debug、-devバリアント、後述のcompatバリアントなどに切り替えます。 - CI/CDに
docker login dhi.ioを追加します。 -
docker build、docker run、テスト、docker scout compareまで通します。docker scout compareは公式クイックスタートでも紹介されています。
迷ったときは、Docker公式が用意しているGordonというAIアシスタントにDockerfileを渡せば、移行案を提示してくれます。ただし、Gordonの提案はまだexperimentalなので、生成された差分は必ずビルドとテストで確認してから採用してください。
検証中に実際に起きたこと
書きながら一番手間取ったのは、go移行時のdhi.io/staticタグの確定でした。公式のGo移行ガイドもstaticイメージのガイドも、<tag>を任意のバリアント名に置き換えるという書き方になっていて、具体タグはカタログのImagesタブに任せる構成です。Hub UIではバリアント名がstatic/debian-12/staticのような階層パスで提示されるのですが、これをそのままdocker pull dhi.io/static:debian-12-staticの形に変換しても引けませんでした。latest、debian-12、alpine-3.22、nonrootなどひととおり試してもnot foundになります。
これは詳細な原因まで突き止め切れていませんが、Communityサブスクリプションで購読中のリポジトリにstaticが含まれていない可能性、あるいはタグが個別に発行されていてHub UIに出ているpathがそのままレジストリ側のtagと一致しない可能性のどちらかと考えています。SelectまたはEnterpriseで自社ネームスペースにミラーリングする運用なら、<your-namespace>/dhi-static:<tag>の形になるので、そもそも参照URL自体が変わります。
実用的な対処は次のいずれかです。
- カタログでstaticの具体タグが確認できる環境なら、それをそのまま使う
- 確認できない環境では、暫定的に最終ステージを
dhi.io/golang:1.25のnon-devランタイムにする - ミラーリング前提のサブスクリプションなら、自社ネームスペース側のtag命名規則に従って指定する
筆者の検証では2番目を採用し、サイズが377MBで止まりました。stateでdhi.io/staticが引ければ2桁MB前後まで落とせるはずなので、本番投入時は最初にここを潰しておくと、無駄なサイズを抱えずに済みます。
引っ掛かりやすい挙動
- 最終ステージで
RUN apt-get installが落ちます。これはDHIランタイムにパッケージマネージャが含まれていないため起きる現象で、該当のRUNを-devステージに移してから成果物だけをCOPYし直すのが正攻法です。 -
docker exec -it <container> shではコンテナに入れません。runtimeイメージにshellを意図的に含めていないことがDHIの設計目的そのものなので、デバッグはDocker Debugに切り替えます。Docker Debugは書き込み可能なエフェメラル層を被せて任意のツールを一時的に追加できるため、長期的には運用の改善にもなります。 - ルートで動いていた書き込みが拒否されます。runtimeバリアントは
nonrootが前提なので、COPY --chownで所有権を整えるか、書き込み先を/tmpや明示的に作った書き込みできるディレクトリに変えてください。 - 80番ポートで
bindできません。同じく非rootの帰結で、特権ポートはCAP_NET_BIND_SERVICE付与なしには開けません。コンテナ内のリスンを8080などに変え、-p 80:8080のようにホスト側でマッピングします。
「DHIにすればDockerfileが不要になる」わけではないことにも注意が必要です。必要なのは責務分離で、ビルドと依存インストールとデバッグは-dev、本番実行はruntimeへ、という線引きをDockerfileに反映させる作業はそのまま残ります。逆に、すでにマルチステージで非rootで成果物だけCOPYしているDockerfileなら、変更はほぼFROMの差し替えと権限調整だけで済みます。
サイズだけが目的ではない
Alpineより常にDHIのほうが小さい、というわけではありません。言語ランタイムや依存の組み合わせ次第で、Alpineのほうがわずかにサイズだけは小さくなる場合もあります。DHIを選ぶ動機は単純な軽量化ではなく、CVE件数の削減、来歴の検証可能性、運用時の攻撃面最小化、コンプライアンス要件への適合のほうに重みがあります。今回の検証でもnodeの差は55MBほどで、サイズ単体の魅力はそこまで大きくありません。
一方で、goのように最終ステージをdhi.io/staticに寄せられる言語では、サイズも一気に削減されます。検証中はCommunityでdhi.io/staticの具体タグが見つからず代替でdhi.io/golang:1.25を使いましたが、本番運用でstaticタグを確定できれば数MB台まで落とせるはずです。
FIPS、STIG、SLA付きのCVEパッチ、社内向けカスタムイメージ、Extended Lifecycle SupportなどはSelectまたはEnterpriseの契約に紐付くので、要件の有無で採用すべきサブスクリプションは変わります。Community版で得られる近ゼロCVEとSBOM、provenanceは無料で、検証や個人プロジェクトであればここから始めるのが手軽です。
まとめ
今回の検証で得られた要点をまとめます。
- DHIへの移行作業は2択に分かれます。すでにマルチステージで非root運用しているDockerfileなら、変更はほぼ
FROMの差し替えと権限調整だけで済みます。一方、単一ステージでapt install、shellスクリプト、ルート書き込み、curlデバッグを多用しているDockerfileは、ビルド責務を-devバリアントに追い出してランタイムを最小化する書き換えが必要になります。前者なら30分、後者なら数時間が目安です。 - サイズ削減を主目的にすると期待外れになりがちなので、まずはCVE件数の削減と来歴検証を主目的に据え、サイズ削減は副次的な恩恵として扱うと判断がぶれません。今回の検証ではnodeで25%、pythonで32%、Railsで66%、goで72%(staticを使えればさらに減る前提)の縮小が観測できましたが、業務利用での価値はSBOMとprovenanceがついてくる点のほうがはるかに大きいです。
採用するときの実務的な順序としては、まず公式の移行例から自分のスタックに該当するページを開き、-devタグとruntimeタグの両方を確認してから書き換えに入ると、迷わずにすみます。docker login dhi.ioをCIに足し、docker scout compareで従来イメージとのCVE差分を取って提案資料に貼ると、合意も取りやすくなります。動作確認まで含めて4言語ぶんでも数十分で終わるので、最初の一本は気軽に試してみる価値があります。
この記事がコンテナセキュリティに関心のある方にとって参考になれば幸いです。
Discussion