1-4. Dockerfile - イメージを自分で作る

所要時間: 40-50分(がっつりなら2セッション分) ゴール: Dockerfile の主要命令を理解し、CMD/ENTRYPOINT・COPY/ADD・ARG/ENV の区別ができる コミット内容: 自作の Dockerfile を ~/learn/docker/day04/ に保存


このレッスンのゴール

  • FROM / RUN / COPY / CMD / ENTRYPOINT / ARG / ENV / WORKDIR を正しく書ける
  • CMD vs ENTRYPOINT、COPY vs ADD、ARG vs ENV の違いを即答できる
  • レイヤーキャッシュが効く順序で命令を並べられる
  • .dockerignore を必ず置く癖を身につける
  • 自分のアプリ(Go / Node / Python など)の Dockerfile が雛形から書ける

なぜ学ぶか(実務悩みベース)

  • 「Dockerfile を書いてください」と言われたとき、ググらずに書きたい
  • 「CMD と ENTRYPOINT どっちで書くべき?」を毎回迷うのを終わらせたい
  • キャッシュが効かずビルド5分が、書き方の工夫で30秒になる
  • セキュリティレビューで「root で実行している」「特権命令ある」と指摘されない

前章とのつながり

1-3 で「イメージ=レイヤーの積み重ね」と知った。今回はそのレイヤーを 自分で1個ずつ積む 設計図の書き方。Dockerfile の各命令が1枚のレイヤーになる仕組みが、書く時の判断(命令の順序、まとめ方)に直結する。


大前提: Dockerfile はなぜ必要か

前回 の最後に出た docker commit は「手で作ったコンテナをイメージ化する」方法だった。でも本番では使わない。理由:

  • 再現できない: 手順がどこにも残らない
  • レビューできない: コード変更のように差分が見えない
  • 間違いが入り込む: 「何かインストールしたっけ?」が積み重なる

Dockerfile とは

イメージを作る手順を「コード」として書き残すファイル。テキストファイル1個に「ベースイメージを使え」「これをインストールしろ」「これをコピーしろ」「これを起動コマンドにしろ」を書く。

Dockerfile があれば:

  • Git で管理できる(差分が見える)
  • 誰がビルドしても同じイメージができる(再現性)
  • レビューできる(チームで品質担保)
  • CI で自動ビルドできる

これが「Infrastructure as Code」の原型。サーバー構築手順書ではなく、コードとして書く文化。

このレッスンでは Dockerfile の 命令を1つずつ深堀り する。特に 混乱しがちなペア を区別できるようにすることが重要。


セッション①: 基本命令(25分)

0. 作業ディレクトリと録画

mkdir -p ~/log ~/learn/docker/day04
cd ~/learn/docker/day04
script ~/log/docker_day04.log

1. 最小の Dockerfile

Dockerfile というファイル名(拡張子なし)で作る:

# ベースイメージ
FROM alpine:3.19
 
# コンテナ起動時に実行するコマンド
CMD ["echo", "Hello from my container"]
# ビルド
docker build -t hello:v1 .
 
# 実行
docker run --rm hello:v1

Dockerfile の構造

  • 大文字の命令 + 引数 の形式(FROM, RUN, COPY など)
  • 上から順に実行 され、各命令が1レイヤーになる
  • # で始まる行はコメント
  • ファイル名は Dockerfile(拡張子なし、大文字始まり)が慣例

ビルドコマンド: docker build -t 名前:タグ .

  • -t でイメージに名前を付ける
  • 最後の .ビルドコンテキスト(カレントディレクトリを指定)。次回で詳しく扱う

2. FROM - ベースイメージ

FROM node:20-alpine
FROM golang:1.22-alpine AS builder
FROM scratch

FROM の本質

「どのイメージを土台にするか」 の指定。Dockerfile はほぼ必ず FROM で始まる(scratch= 何もない、を選んだ場合は別)。

選び方

  • 公式イメージを使う: nginx, postgres, node, python など。docker pull の章で見た命名規則
  • タグを固定: :latest ではなく :20-alpine のように具体的に
  • alpine か slim か: 軽量化なら alpine、glibc 必須なら slim
  • マルチステージ: AS builder のように名前を付けて、後で別ステージから参照(次回

落とし穴: FROM を変えるとそれ以降全レイヤーがキャッシュ無効化される。頻繁に変えないこと。

3. RUN - ビルド時のコマンド実行

FROM ubuntu:22.04
 
# パッケージインストール(よくあるパターン)
RUN apt-get update && apt-get install -y \
    curl \
    git \
    && rm -rf /var/lib/apt/lists/*

RUN の本質

ビルド時にコマンドを実行して、結果を新しいレイヤーとして焼き付ける

用途:

  • パッケージのインストール
  • ファイルの加工(ダウンロード、解凍、ビルド)
  • 設定ファイルの生成

書き方の鉄則: 関連するコマンドは && で繋いで1つの RUN にする。

# NG: 3レイヤー、無駄なキャッシュも残る
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
 
# OK: 1レイヤー、クリーンアップも同じレイヤー内
RUN apt-get update && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*

なぜ繋げる?

  • レイヤー数を減らせる
  • 「インストール後にクリーンアップ」が同じレイヤーで起きるとイメージサイズが小さくなる(別レイヤーに分けるとクリーンアップ前のファイルが下の層に残る)

RUN apt-get update 単独は罠

別レイヤーで apt-get update だけ実行すると、後の apt-get install 時にキャッシュが使われて「古いパッケージリスト」のままインストールされる現象が起きる。必ず同じ RUN 内でセット

4. COPY - ファイルをコピー

# 単一ファイル
COPY package.json /app/
 
# ディレクトリ全体
COPY src/ /app/src/
 
# ワイルドカード
COPY *.go /app/
 
# 複数ソースから1つの宛先へ
COPY file1.txt file2.txt /app/

COPY の本質

ホスト側のファイルをイメージにコピーする。ソースは「ビルドコンテキスト」(後述)内のパス、宛先はイメージ内のパス。

ホスト → イメージへの一方通行。逆(イメージ → ホスト)は別の方法で。

使い所: アプリのコード、設定ファイル、静的ファイルなど、ビルドに必要な物を全部 COPY で入れる。

5. COPY vs ADD(混乱その①)

COPY local.txt /app/local.txt
ADD  local.txt /app/local.txt
 
# ADD は追加機能あり:
ADD https://example.com/file.tar.gz /tmp/   # URLからダウンロード
ADD archive.tar.gz /opt/                    # 自動展開

COPY と ADD の違い

機能COPYADD
ローカルファイルコピーOKOK
URLからダウンロードNGOK
tar/gz の自動展開NGOK(ローカルのtarのみ)

どっちを使うべき?

  • 基本は COPY。Docker 公式も推奨
  • ADD の自動展開は便利に見えて、意図しない挙動の原因 になる(バイナリのtarが勝手に展開されると困る)
  • URL ダウンロードも、RUN curl の方が「キャッシュ管理」「エラー処理」が制御しやすい

結論: 99%の場合 COPY を使う。ADD を使うのは「ローカル tar を展開したい」など明確な意図がある時だけ。

よくあるアンチパターン

ADD https://example.com/install.sh /tmp/
RUN sh /tmp/install.sh

これは RUN curl -fsSL https://example.com/install.sh | sh の方が良い。ADD のURLダウンロードはキャッシュが意図せず効く問題がある。

6. WORKDIR - 作業ディレクトリ

WORKDIR /app
 
COPY . .   # /app にコピーされる
RUN npm install   # /app で実行される

WORKDIR の本質

以降の命令の「カレントディレクトリ」を設定するcd と同じだが、レイヤーをまたいで持続する。

効果:

  • 続く RUN / COPY / CMD / ENTRYPOINT のカレントディレクトリになる
  • docker exec -it container bash で入った時の初期位置にもなる
  • 存在しないパスを指定すると 自動的に作成 される

使い所: ほぼ全 Dockerfile で必須。/app/usr/src/app がデファクト。

アンチパターン: RUN cd /app && ... を書く必要は無い。WORKDIR を使う。


セッション②: 起動コマンドと変数(25分)

7. CMD vs ENTRYPOINT(混乱その②、最重要)

これが Dockerfile で 一番混乱する ポイント。じっくりやる。

# CMD: コンテナ起動時の「デフォルト」コマンド(上書き可)
CMD ["nginx", "-g", "daemon off;"]
 
# ENTRYPOINT: コンテナ起動時に「必ず」実行されるコマンド
ENTRYPOINT ["nginx"]
CMD ["-g", "daemon off;"]   # ENTRYPOINTの引数として渡される

CMD と ENTRYPOINT の違い

両方とも 「コンテナ起動時に実行するコマンド」 を指定するが、性質が違う:

観点CMDENTRYPOINT
役割デフォルトのコマンド必ず実行するコマンド
docker run で上書き簡単(引数を渡すと上書き)困難(--entrypoint フラグが必要)
引数として渡るdocker run img argarg で全置換ENTRYPOINT + docker run の引数

動作例で理解する

Dockerfile:

FROM alpine
CMD ["echo", "default"]

動かす:

docker run myimg            # → echo default
docker run myimg hello      # → hello(CMDは完全に置き換え)

Dockerfile:

FROM alpine
ENTRYPOINT ["echo"]
CMD ["default"]

動かす:

docker run myimg            # → echo default
docker run myimg hello      # → echo hello(CMDが置き換えられる、ENTRYPOINTは残る)

使い分けの指針

  • 「このイメージは特定のコマンドを実行する専用」ENTRYPOINT で固定する
    • 例: nginx イメージは「nginx を起動する」のが仕事。他のコマンドを実行されたくない
    • 例: 自社の CLI ツールのコンテナ
  • 「デフォルトのコマンドはあるが、別のコマンドにも使われる」CMD のみ
    • 例: ubuntu イメージは CMD が bashdocker run -it ubuntu lsls も実行できる
  • 「コマンドは固定、引数だけ可変」ENTRYPOINT + CMD
    • 例: ENTRYPOINT ["myapp"] + CMD ["--help"] → デフォルトはヘルプ表示、引数次第で他の動作

2つの書き方(exec vs shell)に注意

# exec 形式(推奨)
CMD ["nginx", "-g", "daemon off;"]
 
# shell 形式
CMD nginx -g "daemon off;"

違い:

  • exec 形式: コマンドを直接実行。PID 1 はそのプロセスになる。シグナル(SIGTERM等)が直接届くdocker stop で綺麗に止まる
  • shell 形式: /bin/sh -c "..." で実行される。PID 1 は sh で、その子プロセスがコマンド。シグナルが届かないことがあるdocker stop が効かず10秒待った後 SIGKILL される

本番では必ず exec 形式。慣れるまでは「JSON配列で書く」と覚える。

8. ARG vs ENV(混乱その③)

# ARG: ビルド時の変数(docker build --build-arg KEY=VAL で渡せる)
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
 
# ENV: コンテナ実行時の環境変数(docker run -e KEY=VAL で上書き可)
ENV APP_PORT=3000
ENV DATABASE_URL=postgres://localhost/mydb

ARG と ENV の違い

観点ARGENV
スコープビルド時のみビルド時 + 実行時
コンテナ内で見える見えない(消える)見える(env で確認できる)
上書き方法docker build --build-arg KEY=VALdocker run -e KEY=VAL
用途バージョン番号、ビルド用フラグアプリの設定(DB接続文字列、ポート)

使い分け

ARG の用途:

  • ビルド時にしか使わない値(Node.js のバージョン など)
  • シークレット ×(後述)

ENV の用途:

  • アプリの設定値(DB接続URL、ポート、ログレベル)
  • コンテナ内で process.env.X のように読みたい値

ARG にシークレットを入れない

ARG は イメージのメタデータに残るdocker history で見えてしまう。

# 絶対やってはいけない
ARG API_KEY
RUN curl -H "Authorization: $API_KEY" ...

パスワード・APIキー・トークン等は イメージにビルドしない。実行時に -e か Secret マネージャから渡す。

9. EXPOSE - ポートの「ドキュメント」

EXPOSE 80
EXPOSE 80 443

EXPOSE の本質

「このイメージはこのポートを使う」のドキュメント に過ぎない。

誤解しがち: EXPOSE 80 を書いてもポートは外に公開されない。docker run -p 8080:80 のように -p を付けないとアクセスできない。

効果:

  • docker ps で見える
  • docker run -P(大文字 P)で全 EXPOSE ポートをランダムにマップする時に使われる
  • 他の開発者への「このコンテナはこのポートだよ」というシグナル

書いておいた方が良いが、機能的にはほぼ装飾

10. 全部入りの実例

# Node.js アプリの例
FROM node:20-alpine
 
# 作業ディレクトリ
WORKDIR /app
 
# 依存関係を先にコピー&インストール(キャッシュ最適化)
COPY package.json package-lock.json ./
RUN npm ci --only=production
 
# アプリ本体をコピー
COPY . .
 
# 環境変数
ENV NODE_ENV=production
ENV PORT=3000
 
# ポート(ドキュメント用)
EXPOSE 3000
 
# 起動コマンド
CMD ["node", "server.js"]

レイヤーキャッシュを意識した書き順

上の Dockerfile で COPY package.jsonに、COPY . .にしているのには理由がある。

Docker のビルドキャッシュは「上から順に、変更されたレイヤー以降を再ビルド」する仕様。

  • もし COPY . . を最初に書くと、コード1行直しただけで npm ci も再実行される(数十秒〜数分)
  • 依存ファイル (package.json) だけを先にコピー → package.json が変わらない限り npm ci のキャッシュが効く → コード変更時のビルドが爆速

これは 超重要なテクニック。Go なら go.mod / go.sum、Python なら requirements.txt、Rust なら Cargo.toml を先に COPY するのが定石。


セッション③: ベストプラクティス(10分)

11. .dockerignore

.gitignore の Docker 版。ビルドコンテキストから除外する。

# .dockerignore
node_modules/
.git/
.env
*.log
dist/
.DS_Store

.dockerignore がないとどうなる

  • ビルドが遅くなる: 巨大な node_modules/.git/ をデーモンに送る無駄
  • イメージサイズが膨らむ: COPY . . で余計なものまで入る
  • シークレット流出: .env がイメージに入る事故

最低限入れるべきもの

.git
.env
.env.*
node_modules
__pycache__
.pytest_cache
.venv
*.log
dist
build
.DS_Store
Thumbs.db

12. ベストプラクティス集

Dockerfileの王道パターン

  • タグを固定: FROM node:20-alpine のように。:latest 禁止
  • WORKDIR は最初の方で設定: /app が無難
  • 依存ファイルを先に COPY: キャッシュ最適化(上記)
  • RUN は && で連結: レイヤー数とサイズを減らす
  • クリーンアップを同じレイヤー内で: && rm -rf /var/lib/apt/lists/*
  • CMD/ENTRYPOINT は exec 形式(JSON配列): シグナル制御のため
  • .dockerignore を書く: ビルドコンテキストを最小化
  • 非rootユーザーで動かす: USER appuser(セキュリティ)
  • メタデータ: LABEL maintainer="..." などで情報を残す

よくあるアンチパターン

  • FROM ubuntu だけ書いてタグ無し → 半年後に壊れる
  • RUN apt-get update を単独で → キャッシュの罠
  • COPY . . を一番最初に → キャッシュが効かない
  • RUN cd /app → WORKDIR を使え
  • ENV PASSWORD=xxx → 環境変数で渡せ
  • shell形式の CMD → 停止時のシグナル問題
  • .dockerignore 無し → 巨大ビルドコンテキスト

練習課題

~/learn/docker/day04/Dockerfile を自作する:

cd ~/learn/docker/day04
 
# 適当なファイルを準備
cat > hello.sh <<'EOF'
#!/bin/sh
echo "Hello from $(whoami) at $(date)"
EOF
chmod +x hello.sh
 
# Dockerfile を書く
cat > Dockerfile <<'EOF'
FROM alpine:3.19
 
WORKDIR /app
 
COPY hello.sh ./
 
ENV GREETER=takato
 
CMD ["sh", "/app/hello.sh"]
EOF
 
# .dockerignore も
cat > .dockerignore <<'EOF'
.git
*.log
EOF
 
# ビルド
docker build -t mygreet:v1 .
 
# 実行
docker run --rm mygreet:v1
 
# 履歴を確認(各レイヤーが見える)
docker image history mygreet:v1
 
# サイズ確認
docker images mygreet

試してみる

  • CMDを ["echo", "hi"] に変えてビルドし直すと、どのレイヤーまでキャッシュが効く?
  • hello.sh の中身を変えてビルドし直すと?
  • WORKDIR/app から /work に変えると?

答えを docker image history で確認。


締め: 振り返り(10分)

1. セッション録画を終了

exit

2. 今日の発見

- CMD と ENTRYPOINT の違いを自分の言葉で:
- COPY と ADD、なぜ COPY を選ぶ?:
- ARG と ENV、どっちがどっち?:
- 「依存ファイルを先にコピー」の効果を体感した?:
- 詰まったところ:

やらかし事例: Dockerfile の地雷集

事例1: COPY . . を最初に書いてビルド地獄

ソース1行変更で npm install までキャッシュ無効化、毎回5分かかるビルドの完成。依存ファイルを先に COPY、ソースは最後 の鉄則を破ると即発症。

事例2: RUN apt-get update を単独行に書く

翌週ビルドすると、update のレイヤーはキャッシュヒット → 古いパッケージインデックスのまま install → 「存在するはずのパッケージが見つからない」エラー。update && install を1行にまとめる。

事例3: ENV PASSWORD=xxx を Dockerfile に書く

イメージレジストリに push したら、誰でも docker history で見える。本来 --build-arg で渡すか、起動時に -e で投入する。

事例4: shell 形式の CMD で SIGTERM 効かない

CMD python app.py だと内部で /bin/sh -c "python app.py" が走り、PID 1 が sh になる。docker stop の SIGTERM が python に届かず、10秒待ってから SIGKILL。CMD ["python", "app.py"] の exec 形式が正解。

事例5: .dockerignore 無しで node_modules も丸ごとコピー

ホストの node_modulesCOPY . . で巻き込み、イメージサイズ爆増 + 依存解決壊れる。.dockerignore で必ず除外。

対比表: 混乱しがちなペア

ペア違い使い分け
CMD vs ENTRYPOINTCMD は上書き可、ENTRYPOINT は必ず実行コマンドにしたいなら ENTRYPOINT + デフォルト引数を CMD
COPY vs ADDADD は URL/tar展開ができる基本 COPY、URL/tar展開が必要な時だけ ADD
ARG vs ENVARG はビルド時のみ、ENV はランタイムも残るビルドバージョン等は ARG、アプリ設定は ENV
shell形式 vs exec形式shell は sh -c 経由、exec はそのままシグナル制御のため基本 exec
RUN vs CMDRUN はビルド時、CMD は起動時パッケージ入れる→RUN、アプリ起動→CMD

ストーリー: なぜ「依存先COPY」が職人技なのか

ある日同僚が言った。「Dockerfile のビルドが5分かかる、毎回辛い」。彼の Dockerfile は:

COPY . /app
WORKDIR /app
RUN npm install

1文字ソースを変えると COPY のハッシュが変わり、以降のレイヤー全部がキャッシュ無効。npm install も毎回フル実行。

書き直すと:

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .

依存リスト(package.json)が変わらない限り npm install レイヤーはキャッシュヒット。ソース変更は最後の COPY . . だけが再実行。ビルドが30秒に

この差は 「レイヤー=Dockerfile命令1行」「ハッシュ=命令+入力の合計」 という知識から自然に出てくる。1-3 で学んだことが、ここでお金(時間)に変わる瞬間。


自己評価チェックリスト

知識レベル

  • CMD と ENTRYPOINT の違いを説明できる
  • COPY と ADD、ARG と ENV を区別できる
  • exec 形式が望まれる理由(PID 1 とシグナル)を言える
  • レイヤーキャッシュを効かせる「依存先COPY」を腹落ちした

実行レベル

  • Dockerfile を1つ自作してビルドできた
  • FROM / RUN / COPY / WORKDIR / CMD / ENV を実際に使った
  • .dockerignore を書いた
  • docker image history でキャッシュヒットを観察した

メタ認知

  • 自分の過去の Dockerfile(あれば)にアンチパターンが無いか見直した
  • 次に書く Dockerfile で意識する 3 つのポイントを言葉にした

詰まった時のチートシート

命令用途
FROMベースイメージ指定
WORKDIR作業ディレクトリ
COPYホストからイメージへファイルコピー
ADDCOPY + URL/tar展開(基本は使わない)
RUNビルド時のコマンド実行
ENV実行時環境変数
ARGビルド時変数
EXPOSEポートのドキュメント
CMDデフォルトコマンド(上書き可)
ENTRYPOINT必ず実行するコマンド
USER実行ユーザー
LABELメタデータ

「実務OK」基準

  • 自分のアプリをコンテナ化する Dockerfile が書ける
  • CMD/ENTRYPOINT・COPY/ADD・ARG/ENV を間違えない
  • キャッシュ最適化を意識した順序で書ける
  • .dockerignore を必ず作る癖が付いた

次のレッスン

1-5. ビルドと配布 - マルチステージ・push へ。

docker build のビルドコンテキストの落とし穴、マルチステージビルドで本番イメージを小さくする方法、Docker Hub に push する流れを実演する。