Docker 実戦 #5 レジストリへの push とタグ戦略 — :latest の罠
#4 がビルドまでの話だったとすれば、今回は push 後 の運用です。どこに push するか、どんなタグをつけるか、古いイメージをどう片付けるか。
Docker 実戦 での今回の位置:
- #1 FastAPI のコンテナ化
- #2 Django + PostgreSQL compose
- #3 React/Next.js ビルドコンテナ
- #4 CI でのイメージビルド
- #5 レジストリへの push とタグ戦略 — :latest の罠 ← 今回
- #6 クラウドデプロイ — Fly.io / Railway / ECS
タグ一文字が運用を揺さぶることは多いんです。文字だけ見れば些細に見えますが、運用直前に一度整理しておくと長く楽になる論点です。
レジストリ — どこに置くか #
選択肢は大きく分けて 5 つ。
| レジストリ | 位置づけ | 無料枠 | 認証 |
|---|---|---|---|
| Docker Hub | 最も古い、public の標準 | private 1個、pull 制限あり | アカウント + PAT |
| GHCR (GitHub Container Registry) | GitHub リポジトリと自然に紐づく | ほぼ無制限 (public/private) | GITHUB_TOKEN |
| AWS ECR | AWS インフラ内に置きたいとき | 500MB 無料 / 以降 GB 課金 | IAM |
| Google Artifact Registry | GCP 側の標準 | 0.5GB 無料 / 以降課金 | gcloud / WIF |
| プライベートレジストリ | regulated 環境 | — | 自前 |
選定基準を 1 行で:
- GitHub でコードホスティング中なら GHCR. 別途認証セットアップなしで
GITHUB_TOKENがそのまま動く。無料枠は事実上意識する必要がありません。 - AWS 上にデプロイ中(ECS/EKS)なら ECR. 同一リージョンなら pull が速く、IAM で権限管理がきれい。
- 公開 OSS なら Docker Hub. 検索/発見性が圧倒的。ただし、匿名 pull 制限(時間あたり 100 回/IP)があり、CI でベースイメージ取得時に制限に引っかかる事故がときどき発生。
この記事は GHCR 基準で進めます。ECR は AWS トラック #2 ECR で詳しく扱いました。
タグはなぜ難しいか #
Docker の「タグ」は実は イメージに付くラベル にすぎず、何の強制もありません。myapp:1.2.3 と push して、翌日に別のイメージを同じタグで上書きできるんです。これがすべての罠の始まりです。
docker push ghcr.io/me/app:1.2.3 # 初回 push
# ... コード変更 ...
docker push ghcr.io/me/app:1.2.3 # 同じタグで別のイメージを上書きこれが運用で何を意味するかを、次節以降で見ていきます。
:latest が危険な理由
#
:latest は Docker が付与する特別な意味はありません。単に タグを省略すると自動的に付く デフォルト値にすぎないんです。なのに人々は無意識に「最新バージョン」の意味で受け取ります。
docker pull nginx # 実は docker pull nginx:latest
docker run myapp # 実は docker run myapp:latest運用で :latest を使うとこんな問題が起きます。
1. どのイメージが動いているか分かりません。 「production の web コンテナはどのコードが動いているの?」→「myapp:latest です」と答えても答えになっていません。:latest は時間とともに指す対象が変わり続けます。
2. ロールバックができません。 新しいデプロイが壊れたとき「以前の :latest に戻して」が無意味。以前の :latest はすでに消えたラベルです。
3. デバッグが壊れます。 同じタグに対してノードごとに違うイメージがキャッシュされている可能性があります。A ノードの :latest と B ノードの :latest が違う動作をする事故が起きます。
4. キャッシュ無効化が効きません。 Kubernetes の imagePullPolicy: IfNotPresent はイメージタグが同じなら再取得しません。:latest を上書き push しても、すでにキャッシュにあるノードは古いイメージを使い続けます。全ノードが同一イメージで動く前提が崩れます。
5. ビルドとデプロイが分離されません。 「ビルドしたものが正確にそのデプロイ物か?」を保証しにくい。特に :latest と一緒に :sha-abc1234 のような immutable タグも同時に push しないと、時間が経つとビルドとイメージの紐づきが切れます。
じゃあ :latest はいつ使う? ローカル開発/実験。CI ツール(例: actions/cache)のベース。そして README の「1 行で始める」のような チュートリアル用途。本番デプロイには絶対使わないでください。
タグ戦略 — 一つのイメージに複数タグ #
定石は 一つのイメージに複数タグを同時に付けること。同じイメージが複数のラベルで同時に参照できる状態にします。
ghcr.io/me/app:sha-a1b2c3d ← immutable、常にこのコミット
ghcr.io/me/app:1.4.2 ← semver、正確なバージョン
ghcr.io/me/app:1.4 ← semver minor (頻繁に更新)
ghcr.io/me/app:1 ← semver major
ghcr.io/me/app:main ← ブランチ (頻繁に更新)
ghcr.io/me/app:latest ← (内部用 / チュートリアル)各タグの位置:
sha-...— immutable。一度 push されると絶対に変わりません。本番デプロイのimage:フィールドに刺すのはこれを推奨。正確にどのコードが動くか追跡可能。1.4.2— semver patch。リリースタグを push したときに作られる。人が読みやすい。1.4,1— semver minor/major。自動更新タグ。「1.4 の最新 patch を取りたい」のような意図のときに使用。一箇所に刺さず、本番では1.4.2のような正確なバージョンを使ってください。main— ブランチ。開発/ステージング環境で自動デプロイするときに有用。latest— 上で述べた意味。
#4 の metadata-action がこのすべてを一度に作ってくれます。
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=sha-,format=short # 常に
type=ref,event=branch # main, dev
type=ref,event=pr # pr-123
type=semver,pattern={{version}} # v1.2.3 push 時: 1.2.3
type=semver,pattern={{major}}.{{minor}} # 1.2
type=semver,pattern={{major}} # 1
type=raw,value=latest,enable={{is_default_branch}}Immutable タグ — ポリシーで強制 #
GHCR は同じタグでの上書き push が可能です。ポリシーで止められません。だから コンベンション で immutable タグを定義し、それだけを本番デプロイに使います。
- 我々の約束:
sha-*と^v?\d+\.\d+\.\d+$は immutable。一度 push されたら上書きしません。 - 自動タグ(
main,latest,1.4)は意図的に mutable。
他のレジストリには、ポリシーで強制できるものもあります。
- AWS ECR には
tagMutability: IMMUTABLEオプションがあって、本当に同じタグの再 push を拒否します。本番レジストリに推奨。 - GCP Artifact Registry も同様の設定が可能。
- GHCR / Docker Hub はポリシー強制なし。コンベンション + CI 段階で検証する必要があります。
CI で検証するのは単純です。
- name: Check tag uniqueness
if: startsWith(github.ref, 'refs/tags/v')
run: |
TAG="${GITHUB_REF#refs/tags/}"
if docker manifest inspect ghcr.io/${{ github.repository }}:${TAG#v} 2>/dev/null; then
echo "Tag ${TAG} already exists — refusing to overwrite" >&2
exit 1
fiどのタグを本番に刺すか — 結論 #
本番デプロイマニフェスト(例: ECS task definition、Kubernetes Deployment、compose)の image: フィールドには:
image: ghcr.io/me/app:sha-a1b2c3dこのように SHA タグを刺すのが定石。理由:
- 正確にどのコードか明確。
- 同じイメージを指す他のタグが上書き push されても安全。
- ロールバックが単純 — 以前の SHA でマニフェスト 1 行変えるだけ。
- ノード間の一貫性保証。
semver タグは人が見る readme/changelog と外部利用者(ライブラリイメージ)のためのもの。本番マニフェスト内に刺す用途ではありません。
イメージサイズと retention #
タグ戦略と一緒に どれだけ長く保管するか も運用直前に決めるのが良いです。CI が PR/push のたびにイメージを作ると、1 ヶ月で数百個積み上がります。一つのイメージが 100MB なら 30GB が累積します。
レジストリ別の整理方法:
GHCR — リポジトリ settings → Packages で retention policy を設定。または actions/delete-package-versions をワークフローに入れて自動化。
name: Clean up old images
on:
schedule:
- cron: '0 3 * * 0' # 毎週日曜 03:00 UTC
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- uses: actions/delete-package-versions@v5
with:
package-name: 'app'
package-type: 'container'
min-versions-to-keep: 20 # 最低 20 個は保持
delete-only-untagged-versions: false
ignore-versions: '^(latest|main|v\d+\.\d+\.\d+)$' # これらのタグは除外ECR — lifecycle policy がコンソールにあって最もきれい。「untagged イメージ 7 日後削除」「特定 prefix のタグ 30 個だけ保持」のようにルールを書く。
Docker Hub — public は無制限。private は 1 個だけ無料なのでほぼ常に意識する必要あり。
retention ルールを組むときに見落とすと危険なもの — immutable タグ(sha-..., vX.Y.Z)は保護。本番マニフェストが sha タグで刺さっているのにそのタグが整理されたらロールバックできなくなります。上のワークフローの ignore-versions がその設定です。
イメージサイズそのものを減らす — 振り返り #
タグ戦略とは別に、一度整理しておく価値のある論点です。中級 #1 のパターンを再確認すると:
- alpine ベース —
python:3.14-slim(~50MB) vspython:3.14-alpine(~25MB)。ただし、glibc → musl の差で一部のパッケージが入らないことがあります。FastAPI/Django は alpine がよく面倒(ビルド依存)、Node は alpine が無難。 - distroless — Google の baseless イメージ。
gcr.io/distroless/python3-debian12。シェルもないのでデバッグが厳しいが、攻撃面が最小。 - マルチステージ — devDeps/build tool が final に行かないように。
.dockerignore— ビルドコンテキストそのものを減らす。
ビルド後のサイズ確認:
docker images ghcr.io/me/app
docker history ghcr.io/me/app:sha-a1b2c3d --no-trunc
# 各レイヤーがどれだけ占めるか 1 行ずつレイヤー単位で分析したければ、dive のようなツールが良いです。
brew install dive
dive ghcr.io/me/app:sha-a1b2c3d環境別イメージ分離 — :dev vs :prod
#
#3 で触れた通り、Next.js のようにビルド時点で環境変数が刺さると、環境ごとに違うイメージになります。タグを環境 prefix で分離するのがきれいです。
ghcr.io/me/app:prod-sha-a1b2c3d ← プロダクションビルド (NEXT_PUBLIC_API_URL=prod)
ghcr.io/me/app:stage-sha-a1b2c3d ← ステージングビルド (NEXT_PUBLIC_API_URL=stage)CI で環境別ビルドを回すパターン。
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
env: [stage, prod]
include:
- env: stage
api_url: https://api.stage.example.com
- env: prod
api_url: https://api.example.com
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
flavor: |
prefix=${{ matrix.env }}-,onlatest=true
tags: |
type=sha,prefix=sha-,format=short
- uses: docker/build-push-action@v6
with:
context: .
push: true
build-args: |
NEXT_PUBLIC_API_URL=${{ matrix.api_url }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha,scope=${{ matrix.env }}
cache-to: type=gha,mode=max,scope=${{ matrix.env }}サーバーサイドだけ環境変数が分かれるバックエンド(FastAPI/Django)は、一つのイメージで全環境対応可能 — このような分離は不要です。
よくある罠 #
:latest がなぜか古いイメージ — キャッシュ問題。ノードのキャッシュクリアまたは imagePullPolicy 調整。最初から sha タグで行っていれば避けられた問題です。
ある PR のイメージが別の PR を上書き — metadata-action の type=ref,event=pr だけ使えば pr-123 のようにきれいに分離されます。自前で組んで事故るケースが多い。
untagged イメージが 1 万個 — delete-only-untagged-versions: true のような自動整理がないとこうなります。retention policy を一度敷いておくだけで消える問題です。
ECR pull が突然拒否される — IAM 権限問題でなければ、イメージが retention で整理された可能性。lifecycle policy を再確認。
Docker Hub pull rate limit (CI で) — 匿名 pull 制限。ベースイメージを GHCR にミラーするか、Docker Hub アカウントでログインして制限を解除。
まとめ #
- 本番デプロイの
image:フィールドには SHA タグ(sha-a1b2c3d)を刺すのが定石。immutable、追跡可能、ロールバック単純。 :latestは本番に絶対禁止。時間が経つと指すものが変わり、キャッシュ/ロールバック/一貫性すべてが壊れる。- 同じイメージに 複数タグを同時に 付けるのが標準パターン。
metadata-actionが自動化。 - semver 自動更新タグ(
1.4,1)は、ユーザーが「最新 patch を受ける」のような意図のときだけ。本番には正確なバージョンを。 - レジストリは GitHub なら GHCR、AWS なら ECR、OSS なら Docker Hub。大きな骨格はその程度。
- Retention policy は運用直前に一度組んでおけば長く楽。
sha-*/vX.Y.Zは保護ルールに。 - イメージサイズはマルチステージ +
.dockerignoreから始める。alpine/distroless は次の段階。
次の記事(#6 クラウドデプロイ)では、トラックの最後として、このようにビルド/タグ付けされたイメージを 実際のクラウド に上げる流れを扱います。Fly.io / Railway / ECS 三つの選択肢の分かれ道とそれぞれの流れです。