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

所要時間: 40-50分(がっつりなら2セッション分) ゴール: docker build の挙動を理解し、マルチステージで本番イメージを小さくし、レジストリに push できる コミット内容: マルチステージ版 Dockerfile を ~/learn/docker/day05/ に保存


このレッスンのゴール

  • ビルドコンテキストの正体(CLI → デーモンへの転送)を語れる
  • マルチステージビルドで本番イメージを最小化できる
  • arm64 / amd64 のクロスプラットフォーム対応で --platform を使える
  • レジストリへの login → push → pull の流れを止まらず一巡できる
  • BuildKit と従来ビルダーの違いを最低限把握している

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

  • 「ビルドが30分かかる」「イメージが2GBある」を解決したい
  • Apple Silicon Mac で作ったイメージが本番(x86_64)で動かない問題
  • 同僚にイメージを渡す手段が「Slackにtar.gz」になっていて非効率
  • GHCR / ECR / Docker Hub の使い分け、認証エラーの初動

前章とのつながり

1-4 で Dockerfile が書けるようになった。今回は 書いた Dockerfile をどうビルドして、どう運ぶか の運用編。マルチステージビルドは「ビルド時の重い依存を本番イメージに含めない」設計判断で、レイヤー知識(1-3)と Dockerfile 命令(1-4)の合わせ技。


大前提: 「ビルドして動かす」だけで終わらない

前回は Dockerfile の書き方をやった。今日は ビルドの挙動を深く理解して、配布まで やる。

実務で次の困りごとに当たる:

  • ビルドが遅い: 不要なファイルが大量にデーモンに送られている → ビルドコンテキストの理解が必要
  • イメージが巨大: ビルドツール(コンパイラ、開発依存)まで入ってる → マルチステージビルドで解決
  • 他のマシンで動かしたい: 同僚にイメージを渡す、本番にデプロイ → push & pull

これらは1個の Dockerfile を書くだけでは見えてこない、ビルドと配布のワークフロー の話。


セッション①: ビルドの仕組みとビルドコンテキスト(25分)

0. 作業準備

mkdir -p ~/log ~/learn/docker/day05
cd ~/learn/docker/day05
script ~/log/docker_day05.log

1. docker build の基本

docker build -t myapp:v1 .

最後の . は何かを掘り下げる。

ビルドコンテキストの本質

docker build の最後の引数(../path/to/dir)は 「ビルドコンテキスト」のパス を指す。

ビルドコンテキスト = そのディレクトリ以下のファイルすべて が Docker デーモンに送られる範囲。

$ docker build -t myapp:v1 .
  ↓ Docker CLI
[tar で固める] . 配下を全部
  ↓ ネットワーク経由(Mac の場合は VM 経由)
Docker デーモン
  ↓ Dockerfile を読んで処理
イメージ完成

重要な勘違い: COPY で参照できるのは ビルドコンテキスト内のファイルだけCOPY ../some_file.txt のように 外を参照することはできない。これは「コンテキスト = デーモンに送られた範囲」というルールから来る制限。

ビルドコンテキストの落とし穴

  • . をプロジェクトルートにすると、全部送られる: node_modules/(数GB)や .git/(履歴全部)も含まれる
  • 結果として:
    • ビルドが遅い: 送信時間で数十秒~数分
    • デーモン側でディスク逼迫
    • COPY . . で全部入ってしまう: イメージサイズ爆増

解決策: 必ず .dockerignore を書く。前回のレッスン参照。

2. ビルドコンテキストを観察する

# 巨大なファイルを作って試す
dd if=/dev/zero of=bigfile.bin bs=1M count=100  # 100MBのダミー
 
cat > Dockerfile <<'EOF'
FROM alpine
COPY hello.txt /
EOF
 
echo "hi" > hello.txt
 
# ビルド(最初の "Sending build context" を見る)
docker build -t test:1 .
# Sending build context to Docker daemon  100MB のような表示が出る
 
# .dockerignore で除外
echo "bigfile.bin" > .dockerignore
 
# 再ビルド(送信量が激減)
docker build -t test:2 .
 
# 後片付け
rm bigfile.bin
docker rmi test:1 test:2

.dockerignore の威力

同じビルドでもコンテキストサイズが 100MB → 数KB に。本番プロジェクトでは node_modules/.git/ を除外するだけで体感速度がガラッと変わる。

3. ビルドキャッシュをコントロールする

# 標準ビルド
docker build -t cachetest:v1 .
 
# 同じものを再ビルド → 全部キャッシュ使用、一瞬で終わる
docker build -t cachetest:v1 .
 
# キャッシュ無効化
docker build --no-cache -t cachetest:v2 .
 
# 特定の命令から再実行したい → ファイル変更で対応
# (キャッシュは「上から見て初めて差分が出たレイヤー」以降が再実行される)

キャッシュが切れる条件

以下のいずれかが起きるとそのレイヤーから再実行:

  • 命令の文字列が変わった: RUN apt install curlRUN apt install curl wget
  • COPY するファイルの内容が変わった: COPY app.js . で app.js のハッシュが変化
  • ARG / 環境変数が変わった: 値が変わると後続が再実行

キャッシュ最適化のコツ:

  • 変わりにくいもの(依存定義)を先に COPY
  • 変わりやすいもの(ソースコード)を後に COPY

前回の Node 例で package.json を先に COPY していたのはこれ。

--no-cache の使い所

  • 「キャッシュが壊れている気がする」時のリセット
  • CI で「毎回クリーンなビルド」したい時
  • RUN apt update で古いパッケージリストを掴んだ疑いがある時

普段はキャッシュを活用する方が圧倒的に速い。


セッション②: マルチステージビルド(25分)

4. なぜマルチステージが必要か

問題: コンパイル言語(Go、Rust、Java、TypeScript)の本番イメージは、ビルドツールが入ったままだと巨大 になる。

例: Go アプリ

  • ビルド時: Go コンパイラ(800MB)+ 依存ライブラリ
  • 実行時: コンパイル済みバイナリ(10MB)だけで動く

→ 実行に必要のないものを 本番イメージから消したい

マルチステージビルドの本質

1つの Dockerfile に 複数の FROM を書き、前のステージから成果物だけを取り出す 仕組み。

┌─────────────────────┐    ┌─────────────────────┐
│  Stage 1: builder    │    │  Stage 2: runtime    │
│  (ビルド環境)        │    │  (本番イメージ)      │
│                      │    │                      │
│  - Go compiler       │    │  - 軽量ベース        │
│  - 依存ライブラリ    │ →  │  - バイナリだけ      │
│  - ソースコード      │    │                      │
│  - 生成: app バイナリ│    │  CMD ["./app"]       │
└─────────────────────┘    └─────────────────────┘
         (捨てる)             (こっちが配布される)

5. Go アプリの実例(マルチステージなし vs あり)

# main.go を準備
cat > main.go <<'EOF'
package main
 
import (
    "fmt"
    "net/http"
)
 
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello from Go in container")
    })
    http.ListenAndServe(":8080", nil)
}
EOF
 
# go.mod も必要
cat > go.mod <<'EOF'
module hello
go 1.22
EOF

Before: ナイーブな Dockerfile

# Dockerfile.naive
FROM golang:1.22-alpine
 
WORKDIR /app
COPY . .
RUN go build -o app main.go
 
CMD ["./app"]
docker build -f Dockerfile.naive -t hello-go:naive .
docker images hello-go
# サイズ: 約 350MB(golang イメージ全部入り)

After: マルチステージ

# Dockerfile(マルチステージ)
 
# ===== ビルドステージ =====
FROM golang:1.22-alpine AS builder
 
WORKDIR /src
COPY go.mod ./
# 依存があれば go.sum も。今回はライブラリなしなのでスキップ
RUN go mod download
 
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app main.go
 
# ===== ランタイムステージ =====
FROM alpine:3.19
 
WORKDIR /app
# builder ステージから成果物だけコピー
COPY --from=builder /out/app /app/app
 
EXPOSE 8080
CMD ["/app/app"]
docker build -t hello-go:multi .
docker images hello-go
# サイズ: 約 15MB(alpine + バイナリだけ)

マルチステージのインパクト

同じアプリで 350MB → 15MB。実に 20倍以上の差

本番では:

  • デプロイが速くなる: イメージのダウンロード時間が短縮
  • セキュリティ向上: 不要なコンパイラ・ツールが入っていないので攻撃面が減る
  • コスト削減: レジストリの保管容量、転送量

マルチステージの命令解説

FROM golang:1.22-alpine AS builder

ステージに名前 builder を付ける。AS で命名。

COPY --from=builder /out/app /app/app

別ステージ(builder)から /out/app をコピー。これで最終イメージにバイナリだけが残る。

6. もっと小さく: scratch / distroless

# 究極の軽量化: scratch(中身ゼロ)
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app main.go
 
FROM scratch
COPY --from=builder /out/app /app
EXPOSE 8080
CMD ["/app"]
docker build -t hello-go:scratch .
docker images hello-go
# サイズ: 約 6-7MB(バイナリだけ!)

scratchdistroless

  • scratch: 何も入っていない空のイメージ。Goのような静的バイナリ言語と相性最高
  • distroless: Google が提供する「OSコマンドが入っていない」イメージ。シェルすら無いので攻撃が困難。Java/Python など glibc が必要な言語向け

落とし穴: scratch ベースだと docker exec -it bashできない(bash が入っていない)。デバッグは困難。 → 開発用と本番用で Dockerfile を分けるか、distroless の debug 版を使うのが定石。

7. アーキテクチャに注意(M1/M2 Mac の罠)

# Apple Silicon Mac でビルドすると arm64 のイメージになる
docker build -t hello-go:multi .
docker image inspect hello-go:multi | grep Architecture
# "Architecture": "arm64"
 
# 本番が x86_64 (amd64) Linux なら、これではダメ
# クロスビルド: プラットフォームを明示
docker build --platform linux/amd64 -t hello-go:multi-amd64 .

Apple Silicon と本番 Linux のアーキ違い

  • Apple Silicon Mac: arm64 (aarch64)
  • 多くのクラウドの汎用インスタンス: x86_64 (amd64)
  • AWS Graviton 等は arm64 で安いが、まだ少数派

対策

  • --platform linux/amd64 でクロスビルド
  • CI(GitHub Actions等)の Linux amd64 でビルドする運用
  • docker buildx でマルチアーキビルド(linux/amd64,linux/arm64 両対応イメージを1コマンドで)

セッション③: レジストリへの push(15-20分)

8. Docker Hub に push する流れ

# 1. Docker Hub でアカウント作成(事前に)
# https://hub.docker.com/
 
# 2. ログイン
docker login
# Username, Password を入力
 
# 3. イメージにタグを付ける(命名規則: ユーザー名/リポ名:タグ)
docker tag hello-go:multi yourname/hello-go:v1
 
# 4. push
docker push yourname/hello-go:v1

push の仕組み

Docker は レイヤー単位で push する。既にレジストリにあるレイヤー(ベースイメージなど)はスキップされて、自分が追加したレイヤーだけアップロードされる。

結果: 本体は数十MBあっても、push されるのは差分の数MBで済むことが多い。

9. push したイメージを別の場所で pull

# 別マシン or 自分のローカルから(一度ローカルを消す)
docker rmi yourname/hello-go:v1
 
# pull
docker pull yourname/hello-go:v1
 
# 動かす
docker run -d -p 8080:8080 --name go-app yourname/hello-go:v1
curl http://localhost:8080/

これで「配布完了」

イメージを push さえすれば、世界中の Docker が動くマシンで pull → run するだけで同じアプリが動く。「環境差異」問題が解消される瞬間

10. 配布のシナリオ

実務でのイメージ配布パターン

シナリオ①: 同僚にローカルで試させる

  • Docker Hub の private リポジトリに push → 共有
  • もしくは docker save image > image.tar でファイル化、docker load < image.tar で取り込み

シナリオ②: 本番サーバーにデプロイ

  1. ローカル or CI でイメージビルド
  2. AWS ECR / GHCR / GCR にタグ付けして push
  3. 本番サーバー or Kubernetes が pull して run

シナリオ③: CI/CD で自動化

  • GitHub Actions: コードを push → 自動ビルド → ECR に push → ECS/EKS デプロイ
  • 「Dockerfile を書いたら、後は自動」が現代の理想形

11. プライベートレジストリ(ECR の例)

# AWS CLI でログイン情報取得 → docker login
aws ecr get-login-password --region ap-northeast-1 | \
  docker login --username AWS \
  --password-stdin 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com
 
# タグ付け
docker tag hello-go:multi \
  123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/hello-go:v1
 
# push
docker push 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/hello-go:v1

ECR を選ぶ理由

  • AWS リソース(ECS、EKS、Lambda)と統合されている
  • IAM で細かい権限制御
  • private がデフォルト(社内利用向け)
  • 同一リージョンの pull は転送料金が安い

練習課題

cd ~/learn/docker/day05
 
# 1. 既に作った Go アプリのマルチステージ Dockerfile をベースに、以下を試す:
#    - scratch ベースで作り直す
#    - サイズを docker images で比較
 
# 2. --no-cache でビルドしてみる
docker build --no-cache -t hello-go:nocache .
 
# 3. --platform linux/amd64 でクロスビルド
docker build --platform linux/amd64 -t hello-go:amd64 .
docker image inspect hello-go:amd64 | grep -i arch
 
# 4. Docker Hub にアカウントがあれば push を試す(無ければスキップ)
# docker tag hello-go:multi yourname/hello-go:v1
# docker login
# docker push yourname/hello-go:v1
 
# 5. 自作イメージを起動してみる
docker run -d -p 8080:8080 --name myapp hello-go:multi
curl http://localhost:8080/
 
# 6. お片付け
docker rm -f myapp
docker image prune

締め: 振り返り(10分)

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

exit

2. 今日の発見

- マルチステージで何 MB → 何 MB に減った?:
- ビルドコンテキストの理解、腹落ちした?:
- --platform を意識する場面、思い当たる?:
- 本番デプロイのフロー、自分の言葉で書いてみる:
- 詰まったところ:

やらかし事例: ビルドと配布の地雷

事例1: 巨大ビルドコンテキスト

.gitnode_modules.dockerignore で除外せず、docker build の最初に「Sending build context 2.3GB」。Mac だと VM 経由でさらに遅い。

事例2: マルチステージ忘れで「ビルドツール混入」

Go の本番イメージに golang:1.22 のままアプリを乗せ、コンパイラ + 開発依存込みで 1GB 超え。本番に gcc が居る = 攻撃面。

事例3: Apple Silicon で作って x86 本番にデプロイ

Mac (arm64) でビルドしたイメージを EC2 (amd64) で起動 → exec format error--platform linux/amd64 を CI で明示するか、buildx でマルチアーキビルド。

事例4: ECR の認証トークン期限切れ

docker login のトークンは12時間で切れる。CI 用の credential helper を使うか、ジョブの先頭で必ず aws ecr get-login-password を叩く。

事例5: タグ無し push で全部 latest

CI から docker push myapp だけだと :latest で上書き。本番が突然「謎の最新」を pull する事故。:git-sha:v1.2.3 で固定。

対比表: ビルドと配布の手段

手段場面メリット注意点
docker save / loadエアギャップ環境、ちょい共有レジストリ不要容量大、差分転送なし
Docker Hub (public)OSS 配布無料、知名度高rate limit、private は有料
GHCRGitHub と統合した CI/CDprivate 無料、GH OIDC 連携GitHub 依存
ECRAWS ワークロードIAM 連携、同一リージョン安い認証トークン12h
GCR / Artifact RegistryGCP ワークロードCloud Run / GKE 統合GCP 依存
自前レジストリ完全コントロールプライバシー最強運用負担あり

自己評価チェックリスト

知識レベル

  • ビルドコンテキストとは何か、なぜ大きいと遅いかを説明できる
  • マルチステージビルドの動機と仕組みを語れる
  • --platform がいつ必要かを判断できる
  • レジストリの認証 / タグ運用の地雷を3つ言える

実行レベル

  • docker build のビルドコンテキストの仕組みを説明できる
  • .dockerignore の効果を体感した
  • マルチステージビルドで Go アプリを 10MB 台にできた
  • --platform linux/amd64 でクロスビルドした
  • (任意)Docker Hub に push できた
  • キャッシュが切れる条件を理解した

メタ認知

  • 自分の主戦場(言語・クラウド)で使うレジストリを決めた
  • CI/CD で意識する3つのポイントを書き出した

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

やりたいことコマンド
ビルドdocker build -t name:tag .
Dockerfile ファイル名指定docker build -f path/to/Dockerfile -t name .
キャッシュ無効化docker build --no-cache ...
アーキ指定docker build --platform linux/amd64 ...
ビルド引数docker build --build-arg KEY=VAL ...
イメージにタグ付けdocker tag <元> <新タグ>
ログインdocker login
アップロードdocker push <イメージ>
ダウンロードdocker pull <イメージ>
ファイル化docker save <イメージ> > image.tar
ファイルから取込docker load < image.tar

「実務OK」基準

  • マルチステージビルドで本番イメージを最小化できる
  • ビルドコンテキストと .dockerignore を必ず意識する
  • アーキの違い(arm64 vs amd64)を意識して --platform を使える
  • イメージを push して別マシンで pull できる流れを知っている

次のレッスン

1-6. ボリューム - データを永続化する へ。

コンテナはエフェメラル(揮発的)なので、データを残すには別の仕組みが必要。bind mount / named volume / tmpfs の使い分け、パーミッション地獄の対処を学ぶ。