どすこいです!
この記事では、Dockerfileを実務で扱う際に知っておくと大きく効率が上がる設計ガイドを書きました!
Dockerそのものの仕組みには深入りせず、実際にDockerfileを書く場面でつまずきやすい部分だけを解説します!
なお、扱う例はGoを想定しています。
なお、Dockerそのものについて知りたい方は以下のサイトがおすすめです!
https://y-ohgi.com/introduction-docker/
初心者が書くDockerfileには次のような課題が発生しやすいです。
FROM golang:latestWORKDIR /appCOPY . .RUN go mod downloadRUN go build -o serverRUN apt-get updateRUN apt-get install -y gitCMD ["./server"]imageのsize
❯ docker image ls | grep 'myapp'myapp bad dff1f8bf2073 916MBそれでは、各項目に対してダメな例と良い例を提示しながら改善点を解説します。
マルチステージビルドとは、ビルドに必要な環境と実行に必要な環境を分離することです。
特にGoのようなコンパイル言語では効果が大きく、イメージサイズ、安全性、ビルド速度のすべてに効果があります。
ビルド環境と実行環境を同じにしてしまう。
FROM golang:1.22WORKDIR /appCOPY . .RUN go build -o serverCMD ["./server"]この構成の問題点は次の通りです。
ビルドと実行を明確に分離する。
FROM golang:1.22 AS builderWORKDIR /appCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 GOOS=linux go build -o serverFROM gcr.io/distroless/base-debian12WORKDIR /appCOPY --from=builder /app/server .ENTRYPOINT ["./server"]この構成で改善される点は次の通りです。
Dockerのビルドキャッシュはレイヤー単位で管理されており、レイヤーが変更されなければ、以前の結果をそのまま再利用します。
この特性を理解して Dockerfileを設計すると、ビルド時間が大幅に短縮されます。
キャッシュを最大化するための基本的な考えは「変更が発生しない部分を先に書く」ことです。
依存関係のダウンロードより前にアプリ全体をコピーしてしまう記述。
COPY . .RUN go mod downloadRUN go buildこの構成の問題点は次の通りです。
依存関係の取得を先に行い、キャッシュが効くように配置する。
COPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN go buildこの構成で改善される点は次の通りです。
RUN命令はそのたびにレイヤーを生成するため、回数が増えるとイメージサイズが大きくなります。
一方で無理に一つにまとめすぎると可読性が落ち、トラブルシューティングが難しくなります。
適度にまとめることがDockerfile設計の基本方針になります。
必要なパッケージのインストールを複数行に分け、無駄にレイヤーを増やしてしまう例。
RUN apt-get updateRUN apt-get install -y gitRUN apt-get cleanRUN rm -rf /var/lib/apt/lists/*この書き方の問題点は次の通りです。
関連する処理をひとつのRUNにまとめ、必要なクリーンアップも同時に行う。
RUN apt-get update && \ apt-get install -y --no-install-recommends git && \ apt-get clean && \ rm -rf /var/lib/apt/lists/*この書き方で改善される点は次の通りです。
RUNの記述は「関連する操作はひとまとまり」にするのが最適であり、逆に性質が異なる操作を無理にまとめすぎる必要はないです。
Dockerfile の最適化で見落とされやすいのが、ビルドコンテキストに含まれる不要ファイルです。
Dockerはdocker buildの際に現在のディレクトリ(コンテキスト)を丸ごと送信するため、余計なファイルが多いほどビルドが遅くなり、イメージにも不要物が含まれやすくなります。
こうした問題を防ぐための鍵が .dockerignoreです。
.dockerignoreが空のまま、または存在しないケース。
(空ファイル)問題点は次の通りです。
本番に不要なディレクトリとファイルを明確に除外した.dockerignoreを用意する。
.gittestnode_modules*.md*.logDockerfiledocker-compose.yml改善される点は次の通りです。
特に .git とドキュメント類を除外するだけでも、ビルド時間とプッシュ/プル時間が大きく改善されます。
Dockerfileの書き方を改善するのと同じくらい、.dockerignoreの整備は重要です。
コンテナ起動時の挙動を正しく制御するためには、ENTRYPOINTとCMDを適切に使い分ける必要があります。
どちらも「コンテナが起動するときに何を実行するか」を設定しますが、役割は異なります。
ENTRYPOINTは必ず実行されるメインコマンドで、CMDはそのデフォルト引数や上書き可能な設定を扱います。
この違いを理解していないと、意図しない挙動になったり、デプロイ時に柔軟性が失われたりします。
すべてをCMDの中にまとめてしまい、固定すべきコマンドと可変の引数が区別されていない例。
CMD ["./server", "--port=8080"]この書き方の問題点は次の通りです。
アプリのメインコマンドをENTRYPOINTに固定し、変更可能な部分をCMDに委ねる。
ENTRYPOINT ["./server"]CMD ["--port=8080"]改善される点は次の通りです。
実務では、ENTRYPOINTでアプリの起動コマンドを固定し、CMDで実行時の設定を制御する形が標準的です。
さらに、本番環境で構成を変更したい場合はCMDをoverrideするだけで済むため、
開発環境、本番環境、CI環境でパラメータの切り替えが容易になります。
distrolessはGoogleが提供する、アプリケーション実行に必要な最小限のファイルだけを含んだイメージです。
シェルやパッケージマネージャ、不要なライブラリを一切含まないため、非常に軽量で安全性が高い点が特徴です。
従来のalpineやdebianベースのイメージと比べて攻撃対象領域が小さく、実行環境の責務を明確にできます。
https://github.com/GoogleContainerTools/distroless
実行環境として一般的なLinuxディストリビューションを使い続けてしまう。
FROM debian:latestWORKDIR /appCOPY ./server .CMD ["./server"]この書き方の問題点は次の通りです。
distrolessを使い、実行環境を最小単位にする。
FROM gcr.io/distroless/base-debian12WORKDIR /appCOPY --from=builder /app/server .ENTRYPOINT ["./server"]改善される点は次の通りです。
特に distrolessにシェルが含まれない点は、安全性の観点で重要です。
コンテナ内での不要な操作を避けられ、意図しないファイル操作やスクリプト実行を防止できます。
また、CIやCDのパイプラインにおいてもdistrolessの軽量性は有利で、プッシュとプルの速度が改善され、全体のフィードバックサイクルが短くなります。
Dockerコンテナはデフォルトではroot権限で動作します。
これは便利な反面、アプリケーションが予期せず高い権限を持つことになり、権限昇格のリスクや、コンテナ突破時にホスト側への影響を拡大させる要因となります。
ルートレスコンテナは、この問題を避けるためにコンテナを非rootユーザーで実行する考え方です。
Webアプリケーションのようにroot権限を必要としないサービスでは、最小権限での実行を徹底することで安全性が大きく向上します。
DockerfileではUSER命令を使って実現できます。
実行ユーザーを指定せず、rootのままコンテナを動かしてしまう。
FROM gcr.io/distroless/base-debian12WORKDIR /appCOPY --from=builder /app/server .ENTRYPOINT ["./server"]この書き方の問題点は次の通りです。
非rootユーザーを作成し、そのユーザーで実行する。
FROM golang:1.22 AS builderWORKDIR /appCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 GOOS=linux go build -o serverFROM gcr.io/distroless/base-debian12WORKDIR /app# 非rootユーザーに切り替える(distrolessでは65532が推奨)USER 65532COPY --from=builder /app/server .ENTRYPOINT ["./server"]改善される点は次の通りです。
distrolessはユーザー管理機能を提供するため、わざわざuseradを実行せずUSER 65532だけで非root実行ができる点も非常に便利です。
ルートレスコンテナは単なる安全対策ではなく、コンテナ運用の基準として事実上のスタンダードになりつつあります。
理由は次の通りです。
コンテナイメージをビルドした後に必ず実施すべき工程が、イメージのセキュリティスキャンです。
このチェックを省略すると、脆弱性を含んだまま本番環境にデプロイしてしまうリスクがあります。
コンテナイメージにはベースイメージのライブラリやミドルウェア、アプリケーションの依存モジュールなどが含まれています。
これらに既知の脆弱性(CVE)が含まれていたり、パッケージが古く保守終了状態であったりすると、アプリケーションに重大なセキュリティリスクをもたらします。
また、コンテナイメージは "動くパッケージ" であるため、セキュリティ不備がそのまま本番で影響を及ぼしやすく、従来の VM よりも迅速な対策が求められます。
スキャンを行わずそのままイメージをプッシュ・デプロイしてしまう。
# ビルド後すぐに registry へプッシュdocker build -t myapp:latest .docker push myapp:latestこの手順の問題点は次の通りです。
イメージビルド後に Docker Scout CLI を使って脆弱性スキャンを行い、重大な脆弱性を含む場合はプッシュを拒否するように制御する。
docker build -t myapp:latest .docker scout cves myapp:latest --exit-code --only-severity critical,highif [ $? -ne 0 ]; then echo "Critical or High severity vulnerabilities detected. Aborting push." exit 1fidocker push myapp:latestこの流れで改善される点は次の通りです。
セキュリティスキャンを効果的に活用するためには、以下のような観点で設定することが重要です。
Dockerfileは構文がシンプルな一方で、人によって書き方が大きく異なりやすく、レビュー時にスタイルのばらつきが出やすいファイルです。
また、レイヤー構造・キャッシュ・パッケージ管理の扱いなど、正しい知識がないと気づきにくい問題も多く含まれます。
Linterを導入することで、こうした問題の早期発見と品質の標準化が可能になります。
代表的なツールが Hadolint です。
HadolintはDockerfileを静的解析し、以下のような問題を自動で指摘します。
Linter を導入せず、人手だけでレビューしている。
FROM ubuntu:latestRUN apt-get updateRUN apt-get install -y gitこの場合に起きやすい問題は次の通り。
レビュー担当者のスキルに依存し、品質が安定しない。
Hadolintを導入し、自動チェックをCIに組み込む。
brew install hadolinthadolint Dockerfile- name: Run Hadolint uses: hadolint/hadolint-action@v3.1.0 with: dockerfile: Dockerfile改善される点は次の通り。
FROM golang:1.24.5AS builderWORKDIR /app# 依存関係キャッシュを効かせるために先に go.mod だけコピーCOPY go.mod go.sum ./RUN go mod download# アプリコードをコピーCOPY . .# 本番用バイナリをビルドRUN CGO_ENABLED=0 GOOS=linux go build -o serverFROM gcr.io/distroless/base-debian12WORKDIR /app# distrolessでは65532が非rootユーザーとして推奨されているUSER 65532# builderからビルド成果物だけコピーCOPY--from=builder /app/server .# メインコマンド(固定)ENTRYPOINT ["./server"]# 可変部分(例:ポートなど)CMD ["--port=8080"]imageのsize
myapp good 79ba1d2e3d44 31.4MB916MBから31.4MBまでsizeを削減できることができました!👏
Dockerfileは構造が単純に見える一方、書き方ひとつでビルド速度、デプロイ速度、安全性、イメージサイズに大きく差が出ます。
この記事をもとに、ぜひ自分のプロジェクトのDockerfileを見直してみてください!
ごっづぁんです!!
https://docs.docker.jp/develop/develop-images/dockerfile_best-practices.html
https://qiita.com/umanetes/items/e0257dafb920726c4f94
https://zenn.dev/mutex_inc/articles/nodejs-ts-docker-best-practice
https://zenn.dev/forcia_tech/articles/20210716_docker_best_practice
https://sysdig.jp/learn-cloud-native/12-container-image-scanning-best-practices/?_gl=1*1l88vlc*_gcl_au*MzkyNzg5MDY5LjE3NjQwNzQ0NTc.*_ga*MTAxNDMyMTM3My4xNzY0MDc0NDU3*_ga_HZX3EBKYE5*czE3NjQwNzc5NjUkbzIkZzEkdDE3NjQwNzgyMzMkajMyJGwwJGgw
https://www.sysdig.com/jp/learn-cloud-native/dockerfile-best-practices
バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。
解説ありがとうございます。Dockerfile の範疇を少し超えてしまうと思いますが、実務ではさらに環境別設定も必要だと思います。Docker(file) の環境別設定のベストプラクティスも記事にしていただけるとありがたいです。
RUNをまとめようはもう不要でいいと思います。今はキャッシュマウントがあり、イメージの外にダウンロードしてきたものを保存しておいてくれます。apt-getやgo get, pip installなどの対象が100個から101個に増えた場合、レイヤーキャッシュ方式だ101個再ダウンロードしますが、キャッシュマウントだと増えた1つだけで済みますし、最終イメージにゴミも残りません。以前、こちらに書きました。
https://future-architect.github.io/articles/20240726a/
まあキャッシュマウントが入る前から、少なくともマルチステージビルドを使うなら最終イメージ以外は意味がないですし。こちらもここに書きました。この2つを除外してなおRUNをまとめる理由はもう思いつかないです。