あなたのDockerfileは本番環境には大きすぎる

アプリケーションが完成しました。コンパイルも通り、ローカルでも動作し、いよいよ出荷の準備が整いました。Dockerfileを書き、イメージをビルドし、レジストリにプッシュして、サーバーにデプロイします。デプロイは成功しましたが、何かがおかしいと感じています。イメージのサイズが1.2GBもあります。サーバーにプルするのに3分もかかります。ストレージコストは増加の一途です。そして心のどこかで、こう疑問に思います:Goのバイナリを実行するためだけに、パッケージマネージャーやシェルを備えた完全なオペレーティングシステムが本当に必要なのでしょうか?

これこそが、多くのチームが「イメージをビルドすること」と「本番環境に適したイメージをビルドすること」が全く異なることに気づく瞬間です。ローカルテスト用に書いたDockerfileは、本番環境で使うべきDockerfileと同じではありません。イメージが大きく、ビルドが遅く、不要なツールで溢れていると、パイプライン全体が悪影響を受けます。プルには時間がかかり、ビルドは無駄に時間を消費し、脆弱性スキャナーは必要以上に多くの問題を検出します。

幸いなことに、この問題を修正するためにインフラストラクチャ全体を書き換える必要はありません。必要なのは、Dockerfileが実際にどのように動作するか、イメージを小さく保つ方法、そしてビルドを再現可能にする方法という3つのことを理解することです。

Dockerfileがイメージになる仕組み

Dockerfileは命令が書かれたテキストファイルです。各命令が1つのレイヤーを作成します。イメージを再ビルドするとき、Dockerは変更されていないレイヤーをチェックし、キャッシュから再利用します。これが、2回目のビルドが1回目より速い理由です。変更されていないレイヤーは再ビルドされないからです。

問題は、ほとんどの人がDockerfileをセットアップスクリプトを書くように記述していることです。すべてをインストールし、すべてをコピーし、後になってようやく、最終的なイメージにコンパイラ、デバッグツール、アプリケーションの実行とは無関係なシステムユーティリティが含まれていることに気づきます。イメージ内の余分なツールはすべて、プル時の余分な負荷、脆弱性の表面積の増加、そしてイメージに実際に何が含まれているかを理解しようとする際の複雑さの増加につながります。

修正方法は、コードを減らすことではありません。ビルド環境とランタイム環境を分離することです。

マルチステージビルド:最も重要な単一のパターン

マルチステージビルドを使用すると、1つのDockerfile内で複数のステージを定義できます。最初のステージには、アプリケーションのコンパイルに必要なすべてのもの(完全なSDK、コンパイラ、ビルドツール、依存関係)が含まれます。2番目のステージには、コンパイルされたアーティファクトの実行に必要なもの(バイナリ、ランタイムライブラリ、それ以外は何も含まない)だけが含まれます。

Goアプリケーションの場合、次のようになります。

以下の図は、2つのステージがどのように連携するかを示しています。

flowchart TD A["Dockerfile"] --> B["ステージ1: ビルド"] B --> C["FROM golang:1.22-alpine"] C --> D["COPY go.mod go.sum"] D --> E["RUN go mod download"] E --> F["COPY ソースコード"] F --> G["RUN go build -o myapp"] G --> H["バイナリ: myapp"] A --> I["ステージ2: 実行"] I --> J["FROM alpine:3.19"] J --> K["COPY --from=builder /app/myapp /myapp"] K --> L["CMD [\"/myapp\"]"] L --> M["最終的な小さなイメージ (~20-30 MB)"] H -.-> K
# ステージ1: ビルド
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp .

# ステージ2: 実行
FROM alpine:3.19
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]

最初のステージでは完全なGo SDKを使用します。2番目のステージでは、必要最小限のものだけを含む最小限のAlpineイメージを使用します。Go SDK、ソースコード、ビルドキャッシュは最終イメージには決して含まれません。その結果、イメージサイズは1ギガバイトを超えるのではなく、多くの場合20〜30メガバイトになります。

同じパターンは、任意のコンパイル言語にも適用できます。PythonやNode.jsのようなインタプリタ言語の場合、マルチステージビルドを使用して、1つのステージで依存関係をインストールし、インストールされたパッケージのみを最終ステージにコピーすることで、パッケージマネージャーのキャッシュやビルドツールを残すことができます。

セキュリティは除外することから始まる

イメージ内のすべてのツールは潜在的な脆弱性です。Bashのようなシェルやaptのようなパッケージマネージャーは無害に見えるかもしれませんが、攻撃者がコンテナにアクセスした場合、それらのツールは武器になります。攻撃者は新しいソフトウェアをインストールしたり、ペイロードをダウンロードしたり、他のシステムに移動したりすることができます。

原則は単純です:アプリケーションの実行に必要ないものは何も含めないこと。実行中のコンテナを時々デバッグする必要がある場合でも、デバッグツールを本番イメージに保持しないでください。代わりに、別のデバッグ用イメージを作成するか、実行時に必要なツールをマウントするエフェメラルコンテナを使用してください。

本番イメージには、distrolessベースイメージの使用を検討してください。これらのイメージには、アプリケーションに必要なランタイムとライブラリのみが含まれています。シェルもパッケージマネージャーもユーティリティもありません。Googleは、Go、Python、Java、Node.jsを含むいくつかの言語向けにdistrolessイメージをメンテナンスしています。これらは小さく、最小限で、攻撃対象領域を大幅に削減します。

distrolessが制限が厳しすぎると感じる場合は、Alpineが妥当な代替手段です。Alpineは小さく、シェルを含んでいますが、glibcの代わりにmusl libcを使用しているため、一部のアプリケーションで互換性の問題が発生する可能性があります。Alpineにコミットする前に、アプリケーションをAlpineでテストしてください。

再現性はオプションではない

6か月後でも全く同じように再ビルドできないイメージは、責任の種です。再現不可能なビルドの最も一般的な原因は、ベースイメージにlatestタグを使用することです。latestは、メンテナーが新しいバージョンをプッシュするたびに変更されます。今日のビルドではGo 1.22が使用されるかもしれませんが、来月のビルドではGo 1.23が使用され、何かが壊れるまで気づかないでしょう。

ベースイメージは常に特定のバージョンタグに、さらに可能であればダイジェストハッシュに固定してください。ダイジェストハッシュはイメージコンテンツの暗号化チェックサムです。これは決して変更されません。golang:1.22-alpine@sha256:abc123...を使用すれば、いつ、どこでビルドしても、まったく同じベースイメージを確実に取得できます。

FROM golang:1.22-alpine@sha256:abc123def456 AS builder

これは、マルチステージビルドで使用される中間イメージを含め、プルするすべてのイメージに適用されます。特定のバージョンのダイジェストが見つからない場合は、golang:1.22-alpineではなくgolang:1.22.0-alpine3.19のように、最も具体的なタグを使用してください。

レイヤーの順序がビルド速度に影響する

Dockerは命令の順序に基づいてレイヤーをキャッシュします。Dockerfileの早い段階でコピーされたファイルを変更すると、後続のすべてのレイヤーが無効化され、再ビルドされます。遅い段階でコピーされたファイルを変更すると、その時点以降のレイヤーのみが再ビルドされます。

実用的なルールは次のとおりです:めったに変更されない命令を上部に、頻繁に変更される命令を下部に配置します。

  • システム依存関係を最初にインストールします。
  • 依存関係マニフェスト(go.modgo.sumなど)をコピーし、依存関係のダウンロード手順を実行します。
  • ソースコードを最後にコピーします。

こうすることで、ソースコードのみを変更した場合、依存関係のインストール手順はキャッシュから再利用されます。Dockerは変更されていないパッケージを再ダウンロードしないため、ビルドが高速になります。

Dockerfileのための実用的なチェックリスト

次にイメージを本番環境にプッシュする前に、このチェックリストを確認してください。

  • 最終イメージには、アプリケーションの実行に必要なものだけが含まれていますか?
  • ビルドツール、SDK、ソースコードは最終ステージから除外されていますか?
  • ベースイメージはlatestではなく、特定のバージョンまたはダイジェストに固定されていますか?
  • ベースイメージは、完全なOSイメージではなく、最小限(distrolessまたはAlpine)ですか?
  • めったに変更されない命令は、頻繁に変更される命令の前に配置されていますか?
  • 本番イメージに、絶対に必要でない限り、シェルやパッケージマネージャーはありませんか?

最も重要なこと

Dockerfileは単なるビルドスクリプトではありません。ビルドプロセスと本番環境との間の契約です。優れたDockerfileは、小さく、安全で、再現可能なイメージを生成します。悪いDockerfileは、プルが遅く、デバッグが難しく、自信を持って再ビルドすることが不可能なイメージを生成します。

次にDockerfileを書くときは、最終的なイメージを念頭に置いて始めてください。自問してみてください:このアプリケーションが実際に実行するために必要なものは何か?そして、それ以外のすべてを除外してください。パイプラインはより高速になり、デプロイはよりスムーズになり、セキュリティチームの心配事が一つ減ります。