CI/CDパイプラインでのDockerイメージビルド

あなたのローカルマシンで完璧に動作するDockerfileがあるとします。docker buildを実行すればイメージがビルドされ、アプリケーションは正常に動きます。ところが、同じDockerfileをパイプラインにプッシュすると、挙動が変わってきます。

ローカルでは2分だったビルドが、今では15分かかる。昨日動いていたイメージが、今日は理由もなく失敗する。そして、ロールバックのために古いバージョンを再ビルドしようとすると、元のイメージとは異なる動作をする。

これらの問題は偶然ではありません。開発者のマシンでイメージをビルドすることと、自動化されたパイプラインでビルドすることの間にあるギャップから生じます。このギャップを理解することが、問題解決への第一歩です。

パイプラインに移行すると何が変わるか

パイプラインでイメージをビルドするとき、3つのことが根本的に変わります。

第一に、ビルドは他人のマシンで実行されなければなりません。そのマシンは異なるリソース、異なるネットワーク環境、異なるファイルシステムを持っている可能性があります。Dockerfileは、どこで実行されても動作する必要があります。

第二に、パイプラインはコードが変更されるたびにイメージを再ビルドしなければなりません。これはオプションではありません。再ビルドをスキップすると、デプロイは古いコードで実行されることになります。しかし、毎回再ビルドするということは、すべてのコミットが完全なビルドプロセスをトリガーし、そのプロセスは開発のスピードを維持できるほど高速である必要があります。

第三に、ビルドは再現可能でなければなりません。同じソースコードからは、いつ、どこでビルドが実行されても、同じイメージが生成される必要があります。再現性がなければ、古いコミットにロールバックしても、まったく同じアプリケーションの動作が復元されると信頼できません。

以下の図は、ソースコードからレジストリへのプッシュまでの、Dockerビルドパイプラインの典型的なステージを示しています。

flowchart TD A[ソースコード] --> B[チェックアウト] B --> C[ビルドコンテキスト設定] C --> D[Dockerビルド] D --> E{キャッシュヒット?} E -- Yes --> F[キャッシュレイヤー再利用] E -- No --> G[新しいレイヤーをビルド] F --> H[イメージにタグ付け] G --> H H --> I[レジストリにプッシュ]

ビルドコンテキストを制御する

ビルドコンテキストとは、docker buildを実行するときにDockerデーモンに送信するフォルダのことです。ローカルマシンでは、通常はプロジェクトフォルダです。パイプラインでも同じことが起こりますが、大きなコンテキストの影響はより深刻です。

ビルドコンテキスト内のすべてのファイルがDockerデーモンに送信されます。リポジトリにnode_modules、Python仮想環境、コンパイル済みバイナリが含まれている場合、それらのファイルはビルドに不要であっても転送されます。これにより、すべてのパイプライン実行が遅くなります。

解決策は.dockerignoreファイルです。これはDockerビルド用の.gitignoreのようなものです。イメージに不要なもの(依存関係フォルダ、キャッシュディレクトリ、.git履歴、テストフィクスチャ、ドキュメントなど)をすべてリストアップします。コンパクトなビルドコンテキストは、より高速なビルドと少ないネットワークトラフィックを意味します。

キャッシュを活用する

Dockerはイメージをレイヤーでビルドします。Dockerfileの各命令が1つのレイヤーを作成します。再ビルド時に、Dockerは各レイヤーが変更されたかどうかをチェックします。レイヤーが変更されていなければ、Dockerは前回のビルドのキャッシュ結果を再利用します。

このキャッシュメカニズムは、高速ビルドのための最大の味方です。ただし、キャッシュがパイプライン実行間で保持される場合に限ります。

ローカル開発では、キャッシュはマシン上に残ります。パイプラインでは、明示的に保存しない限り、ランナーが終了するとキャッシュは消えます。一部のCIシステムは組み込みのDockerレイヤーキャッシュを提供しています。そうでない場合は、手動で設定するか、すべてのビルドをゼロから開始することを受け入れる必要があります。

キャッシュが機能していても、Dockerfile内の命令の順序によって、実際に使用できるキャッシュの量が決まります。黄金律は、変更頻度が低いものを先にコピーすることです。

Node.jsアプリケーションの場合、これはpackage.jsonpackage-lock.jsonを残りのソースコードより先にコピーすることを意味します。これらのファイルをコピーした直後にnpm installを実行します。その後、アプリケーションコードをコピーします。この順序により、依存関係のインストールは、アプリケーションコードの1行を変更したときではなく、依存関係が実際に変更されたときのみ再実行されます。

同じ原則はあらゆる言語に適用されます。Pythonプロジェクトはrequirements.txtまたはpyproject.tomlを最初にコピーします。Goプロジェクトはgo.modgo.sumを最初にコピーします。パターンは普遍的です。安定した依存関係と変更されるアプリケーションコードを分離することです。

ビルド引数で柔軟性を高める

Dockerfileは、環境によって変わる値をハードコードすべきではありません。ベースイメージのバージョン、環境名、プライベートレジストリのアクセストークンなどは、Dockerfileの外部から指定する必要があります。

Dockerはこの目的のためにARGを提供しています。Dockerfile内にプレースホルダーを定義し、パイプラインがビルド時に実際の値を埋め込みます。

ARG BASE_IMAGE_VERSION=20.04
FROM ubuntu:${BASE_IMAGE_VERSION}

パイプラインでは、実際の値を渡します。

docker build --build-arg BASE_IMAGE_VERSION=22.04 .

これにより、Dockerfileは汎用的で再利用可能になります。1つのDockerfileで、開発、ステージング、本番のビルドを重複なく扱えます。

再現可能なビルドを確保する

再現性とは、同じソースコードを異なるタイミングでビルドしても、同じイメージが生成されることを意味します。これがなければ、ロールバック戦略、監査証跡、セキュリティスキャンを信頼できません。

再現性を損なう一般的な要因は3つあります。

第一に、ベースイメージにlatestタグを使用すること。latestタグは時間とともに変化します。今日のlatestは明日のlatestではありません。ベースイメージはubuntu:22.04node:20-alpineのように、特定のバージョンタグに固定します。

第二に、依存関係のバージョンを固定しないこと。package.json^4.0.0のようなバージョン範囲を指定すると、時間の経過とともに異なるバージョンに解決されます。package-lock.jsonyarn.lockのようなロックファイルを使用して、正確なバージョンを固定します。

第三に、ビルドのタイムスタンプやビルドメタデータをイメージに埋め込むこと。ビルドプロセスが現在の日付をイメージ内のファイルに書き込むと、ソースコードが同一でもビルドごとにそのファイルが異なります。特別な運用上の理由がない限り、これは避けてください。

イメージをレジストリに保存する

パイプラインがイメージをビルドしたら、サーバーやKubernetesクラスターがプルできる場所が必要です。それがコンテナレジストリです。

パイプラインは、意味のある識別子でイメージにタグを付ける必要があります。一般的なパターンは、コミットSHAをプライマリタグとして使用し、ブランチやセマンティックバージョンの追加タグを付けることです。

docker tag myapp:${COMMIT_SHA} registry.example.com/myapp:${COMMIT_SHA}
docker push registry.example.com/myapp:${COMMIT_SHA}

これにより、ビルドされたすべてのイメージへの永続的な参照が得られます。任意のコミットに戻って、そのコミットからビルドされた正確なイメージをいつでもプルできます。

実践的なチェックリスト

Dockerビルドをパイプラインにプッシュする前に、以下の項目を確認してください。

  • .dockerignoreで依存関係フォルダ、キャッシュ、.gitを除外している
  • Dockerfileで依存関係ファイルをアプリケーションコードより先にコピーしている
  • ベースイメージのタグをlatestではなく特定のバージョンに固定している
  • 依存関係のバージョンをロックファイルで固定している
  • 環境固有の値にビルド引数を使用している
  • パイプラインがDockerレイヤーキャッシュを実行間で保存している
  • イメージにコミットSHAでタグを付け、レジストリにプッシュしている

これがパイプラインにとって意味すること

パイプラインでイメージをビルドすることは、単に別のマシンでdocker buildを実行することではありません。Dockerfileとパイプラインが連携するように設計することです。レイヤーの順序とビルドコンテキストを考慮した適切に構造化されたDockerfileは、より高速にビルドされます。キャッシュを保持し、イメージに適切にタグを付けるパイプラインは、信頼性が高く再現可能なアーティファクトを提供します。

今日ビルドしたイメージは、6か月後に同じコミットから再ビルドしても同じイメージであるべきです。その一貫性こそが、デプロイを予測可能にし、ロールバックを安全にするのです。