Docker 実戦 #5 レジストリへの push とタグ戦略 — :latest の罠

読了 9分

#4 がビルドまでの話だったとすれば、今回は push 後 の運用です。どこに push するか、どんなタグをつけるか、古いイメージをどう片付けるか。

Docker 実戦 での今回の位置:

タグ一文字が運用を揺さぶることは多いんです。文字だけ見れば些細に見えますが、運用直前に一度整理しておくと長く楽になる論点です。

レジストリ — どこに置くか #

選択肢は大きく分けて 5 つ。

レジストリ位置づけ無料枠認証
Docker Hub最も古い、public の標準private 1個、pull 制限ありアカウント + PAT
GHCR (GitHub Container Registry)GitHub リポジトリと自然に紐づくほぼ無制限 (public/private)GITHUB_TOKEN
AWS ECRAWS インフラ内に置きたいとき500MB 無料 / 以降 GB 課金IAM
Google Artifact RegistryGCP 側の標準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 が付与する特別な意味はありません。単に タグを省略すると自動的に付く デフォルト値にすぎないんです。なのに人々は無意識に「最新バージョン」の意味で受け取ります。

タグ省略 → :latest
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 — 上で述べた意味。

#4metadata-action がこのすべてを一度に作ってくれます。

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 で検証するのは単純です。

immutable タグ保護
- 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: フィールドには:

推奨 — SHA タグ
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 をワークフローに入れて自動化。

GHCR 自動整理
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) vs python: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 のようなツールが良いです。

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-actiontype=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 三つの選択肢の分かれ道とそれぞれの流れです。

X