Code Engine 用 Dockerfile の記述
コンテナー・イメージにコードをビルドする前に、IBM Cloud® Code Engine内で Docker ビルドがどのように機能するかについての基本を学習してください。 その後、そうした目標を Dockerfile が達成するためのいくつかのベスト・プラクティスを見ていきます。 以下の例は、イメージ・サイズを小さくすること、Code Engine アプリケーションとのインターオペラビリティーを向上させること、非 root ユーザーとしてコンテナーを実行することに焦点を当てています。
ビルド構成を作成するときは、選択可能な 2 つの方式のどちらを使用するかを決定します。
-
Cloud Native Buildpack は、ソース・コードを検査し、コードのベースになっているランタイム環境と、ソースからコンテナー・イメージがビルドされる方法を検出します。 これはいくつかのプログラミング環境でサポートされています。 ビルド方式の選択に、サポート・リストがあります。
-
Docker ビルドは、Dockerfile 内の記述に基づいて、コンテナーを作成します。 その後、Dockerfile ファイルはソース・コードとともにコミットされてコンテナーが作成されます。
どちらの方式もビルドに使用できますが、例えば以下の場合は、Dockerfile を選択することをお勧めします。
- プログラミング環境がビルドパックでサポートされていない。
- プロジェクト・ビルドで追加のパッケージをコンテナーにインストールする必要がある。
Dockerfile の基本
Dockerfile は、コンテナーをどのようにビルドするかを記述したものです。 Dockerfile 内で、ビルド時と実行時に必要な必須ツールを含む基本イメージを選択します。 ビルド・コンテキストからイメージへのファイルのコピー、コマンドの実行、環境変数や公開ポートなどのランタイム動作の定義、およびENTRYPOINT
の設定を行うことができます。 ENTRYPOINT
は、コンテナーの開始時に呼び出されるコマンドによって設定されます。
Dockerfile 命令を指定する方法について詳しくは、 Dockerfile referenceを参照してください。
Code Engine ビルドでは、Git リポジトリーを指すソースを定義します。 Docker ビルドで使用できるコンテキストは、デフォルトでは、Git リポジトリーのルート・ディレクトリーです。 例えば、src
という名前のディレクトリーがリポジトリーにある場合は、Dockerfile で COPY
ステートメントを使用して、このディレクトリーをイメージにコピーできます。 以下に例を示します。
COPY src /app/src
Git リポジトリー全体をコピーするには、次の COPY
ステートメントを指定します。
COPY . /app/src
Git リポジトリー全体をコピーするが、一部のファイル (リポジトリーの README.md
など) を除外する場合は、 .dockerignore
ファイルを追加できます。 .gitignore ファイルに指定したファイルとディレクトリーも無視するには、同じファイルを使用します。 同じファイルを使用することで、ローカルで実行するビルドで使用できるファイルのセットが、Code Engine で実行されるビルドで使用できるものと同じになります。
アプリケーション・ファイルをコピーするときには、オペレーティング・システム・ファイルとの競合を回避するために、必ず、ルート (/
) に直接コピーするのではなく、ルートのサブディレクトリーにコピーしてください。 アプリケーション・ディレクトリーに名前を付けるときには、Unix ベースのオペレーティング・システム、Kubernetes および Code Engine のビルドで予約されている、/bin
、/dev
、/etc
、/lib
、/proc
、/run
、/sys
、/usr
、/var
、/workspace
といった名前を使用しないでください。 アプリケーション・ディレクトリーを /app
と命名するのがベスト・プラクティスです。
Code Engine サンプル・リポジトリーのように、ディレクトリーに編成されているさまざまなアプリケーションのソースがソース・コード・リポジトリーに含まれている場合は、コンテキストとしてサブディレクトリーを使用できます。 ibmcloud ce build create
コマンドで、--context-dir
オプションを使用してサブディレクトリーを指定します。
Dockerfile がコンテキスト・ディレクトリーにない場合は、--dockerfile
引数を使用して指すことができます。
Code Engineでビルドする前に、ローカル・システムで Docker ビルドを試すには、 Docker Desktopを使用できます。
コンテナー・イメージのサイズの縮小
コンテナー・イメージのサイズを小さくすることは、複数の側面で価値があります。
- コンテナー・レジストリーにイメージを保管するために必要なスペースが小さくなります。 他のイメージの割り当て量を確保でき、コストを節約できます。
- 場合によっては、サイズの小さいイメージは、サイズの大きいイメージよりも速くコンテナー・レジストリーに転送できるため、ビルドの実行にかかる時間が短くなることがあります。 これもコストの節約になります。
- イメージをプルするために必要な時間が短くなるので、イメージを使用するアプリケーションやジョブの開始が速くなります。 アプリケーションやジョブを実行するために必要なリソースは、イメージがプルされている間予約されているので、これもコストの節約になります。 高速起動時間は、Code Engine 内の各アプリケーションに特に関係があります。アプリケーションがゼロにスケールダウンされても、ユーザーの要求に対する許容応答時間が保証されるからです。
ビルドのサイズを削減するために、以下のベスト・プラクティスを確認してください。
複数のコマンドを単一の RUN
ステートメントにまとめてイメージ・サイズを小さくする
この例では、Node.jsなどのソフトウェアをコンテナー・イメージにインストールしなければなりません。Node.js の基本イメージを使用して、Node.js アプリケーションをビルドします。
Node.js を手動でインストールする場合は、次の Dockerfile 例を使用できます。
FROM ubuntu
RUN apt update
RUN apt upgrade -y
RUN apt install -y nodejs
RUN apt clean
RUN rm -rf /var/lib/apt/lists/\*
この例では、更新し、アップグレードし、Node.js をインストールし、クリーニングして、ファイルのキャッシュを削除するために、いくつもの RUN
ステートメントが使用されています。 この一連のステートメントは正しいのですが、複数の RUN
ステートメントを使用することは最良の実装方法ではありません。 Dockerfile ファイルが処理されるとき、Dockerfile ファイル内の各ステートメントが 1 つの層を形成します。
各層には、作成または更新されたファイルとともに、何が削除されたかについての情報も含まれています。 コンテナー・イメージはすべての層の集合です。 あるステートメントでファイルが追加され、2 つ目のステートメントでそのファイルが削除された場合、結果のイメージでは、最初のステートメントの層にまだファイルが含まれていて、2 つ目の層でそのファイルに削除済みのマークが付けられます。
ディスク・スペースを節約するには、単一の RUN
ステートメントを指定することで、このコマンド・セットに対応する単一の層を作成するようにします。
FROM ubuntu
RUN apt update && apt upgrade -y && apt install -y nodejs && apt clean && rm -rf /var/lib/apt/lists/\*
1 行に 1 コマンドを記述する理解しやすい Dockerfile を維持するには、コードに改行を追加します。
FROM ubuntu
RUN \
apt update && \
apt upgrade -y && \
apt install -y nodejs && \
apt clean && \
rm -rf /var/lib/apt/lists/\*
このタイプのステートメントを使用することにより、コンテナーのイメージ・サイズを約 174 MB から 147 MB ほどに縮小できます。 Ubuntu 基本イメージと Node.js パッケージは変わることがあるため、サイズ例は実際には異なる場合があります。
このタイプのベスト・プラクティスは、パッケージのインストールだけでなく、類似のタスクにも適用できます。 例えば、ソフトウェア・パッケージ (この場合も Node.js) のダウンロードと解凍は、次の例のようになります。
FROM ubuntu
RUN apt update
RUN apt upgrade -y
RUN apt install -y curl
RUN curl https://nodejs.org/dist/v16.14.2/node-v16.14.2-linux-x64.tar.gz -o /tmp/nodejs.tar.gz
RUN mkdir /opt/node
RUN tar -xzf /tmp/nodejs.tar.gz -C /opt/node --strip 1
RUN rm /tmp/nodejs.tar.gz
RUN apt remove -y curl
RUN apt clean
RUN rm -rf /var/lib/apt/lists/\*
rm
コマンドは、ダウンロードされた nodejs
パッケージを削除しますが、この場合も複数の RUN
ステートメントが別々の層を形成します。 さらに、nodejs
パッケージをダウンロードするために、curl
パッケージが一時的にインストールされます。 curl パッケージ自体は後で削除されますが、暗黙的にインストールされたその依存関係は残り続けます。
より適切な Dockerfile は、次の例のようになります。
FROM ubuntu
RUN \
apt update && \
apt upgrade -y && \
apt install -y curl && \
curl https://nodejs.org/dist/v16.14.2/node-v16.14.2-linux-x64.tar.gz -o /tmp/nodejs.tar.gz && \
mkdir /opt/node && \
tar -xzf /tmp/nodejs.tar.gz -C /opt/node --strip 1 && \
rm /tmp/nodejs.tar.gz && \
apt remove -y curl && \
apt auto-remove -y && \
apt clean && \
rm -rf /var/lib/apt/lists/\*
関連するコマンドは単一の RUN
ステートメントにまとめられ、依存関係を削除するために apt auto-remove
コマンドが追加されています。 この例では、イメージ・サイズが約 222 MB から 162 MB ほどに縮小されます。
ごく小さい基本イメージを使用する
前記の例では、Ubuntu を基本イメージとして使用しています。 このイメージには便利なユーティリティーが多数含まれていますが、基本イメージ内のユーティリティーが多いほど、基本イメージのサイズが大きくなります。 また、含めるユーティリティーが多いほど、セキュリティーの脆弱性が存在する可能性が高まり、そのためにイメージの再ビルドが必要になります。 この両方の問題を回避するために、より小さい基本イメージを使用してください。 以下に例を示します。
-
Alpine はサイズの小さい公式 Docker イメージです。 Java や Node.js のようなプログラミング環境では、Alpine に基づいたタグがしばしば見つかります。
-
Google Container Tools の Distroless イメージにはオペレーティング・システムのツールがまったく含まれていませんが、Java や Node.js などのさまざまな言語に必要なランタイム環境が含まれています。
program.js ファイル内の Node.js プログラムを実行するコンテナー・イメージをビルドして、これらの基本イメージを比較してください。 Ubuntu の場合、Dockerfile ファイルは次の例のようになります。
FROM ubuntu
RUN \
apt update && \
apt upgrade -y && \
apt install -y nodejs && \
apt clean && \
rm -rf /var/lib/apt/lists/\*
COPY program.js /app/program.js
WORKDIR /app
ENTRYPOINT ["node", "program.js"]
Alpine の場合、イメージは Node.js から直接取得します。
FROM node:16-alpine
COPY program.js /app/program.js
WORKDIR /app
ENTRYPOINT ["node", "program.js"]
Distroless の場合は、次の例を使用します。
FROM gcr.io/distroless/nodejs:16
COPY program.js /app/program.js
WORKDIR /app
CMD ["program.js"]
Ubuntu ベースのイメージは 147 MB ですが、Alpine に基づいたイメージは 90 MB、Distroless に基づいたイメージは 94 MB です。
イメージ・サイズを小さくするためにソースとビルド・ツールを含めない
前記の Node.js ベースの例では、単一のソース・ファイルがコンテナー・イメージに追加されています。 この例では、コンパイルが不要だったため、単一のソース・ファイルを使用できます。 一方、コンパイルが必要な場合は、必要なツールをビルドだけに使用し、それらのツールを結果のイメージに含めないでください。 例えば、Maven を使用する Java アプリケーションを指定するとします。 まずいコーディングの Dockerfile は、次の例のようになります。
FROM maven:3-jdk-11-openj9
WORKDIR /app
COPY . /app
RUN mvn package
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/target/java-application-1.0-SNAPSHOT-fat.jar"]
結果のコンテナー・イメージには、Maven からのすべてのソース・コードとすべての中間ファイル (成果物キャッシュ、後で JAR ファイルにパッケージ化されるクラス・ファイルなど)、および Maven ビルド・ツールが含まれます。 さらに、実行時に必要とされる Java ランタイム環境 (JRE) ははるかに小さいのに、Java Development Kit (JDK) も含まれます。 結果として、イメージ・サイズは 466 MB になります。
イメージを小さくするには、マルチステージ・ビルドという機能を使用します。 Docker ビルドのステージごとにそれ専用の基本イメージがあり、そのステージ内でコマンドを実行できます。 最後に最終イメージがビルドされ、それまでのステージの成果物内でコピーされます。 ソース・コードからコンテナー・イメージをビルドする場合、一般的なパターンは次の 2 つのステージを使用することです。
- ソース・コードをランタイムのバイナリーにコンパイルするために必要なすべてのツールを含む基本イメージを使用するビルダー・ステージ。
- ランタイム・ステージ: バイナリーを実行するために必要なランタイム環境を含む基本イメージを使用します。 ビルダー・ステージのバイナリーは、このステージにコピーされます。
Maven プロジェクトの場合、結果は次の例のようになります。
FROM maven:3-jdk-11-openj9 AS builder
WORKDIR /app
COPY . /app
RUN mvn package
FROM adoptopenjdk:11-jre-openj9
WORKDIR /app
COPY --from=builder /app/target/java-application-1.0-SNAPSHOT-fat.jar /app/java-application.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/java-application.jar"]
この例では、ビルダー・ステージでは、Maven 基本イメージを使用してアプリケーションの JAR ファイルをビルドしています。 ランタイム・ステージでは、より小さい JRE 基本イメージを使用しています。 COPY --from=STAGE_NAME
コマンドを使用することにより、ビルダー・ステージの JAR ファイルがランタイム・ステージにコピーされます。 結果のイメージのサイズは 242 MB で、約 48%
の節約になります。
このパターンは、コンパイルを行う必要がある他のプログラミング言語にも使用できます。
- ビルド (例えば Angular ビルドや React ビルド) を必要とする Node アプリケーション。 この場合、ビルダーとランタイムの基本イメージは最終的に同じ (両方ともノード) になる可能性がありますが、ビルド時の成果物とソースをすべてランタイム・イメージにコピーする必要があるわけではありません。
- ランタイム環境なしで実行されるネイティブ実行可能ファイルにソース・コードをコンパイルするプログラミング言語 (例えば Go や Rust)。
ネイティブ実行可能ファイルを生成し、ランタイム環境をまったく必要としない言語の場合は、別の Docker 機能 (scratch) をランタイム・ステージに使用します。 scratch は FROM
コマンドで基本として使用できますが、最終的なコンテナー・イメージではありません。 むしろそれは、Docker ビルドで基本イメージをまったく使用しないように指示します。 基本イメージからのオペレーティング・システム・ファイルを全く含めず、ただ単一ファイル
(ビルダー・ステージからコピーされたバイナリー) のみを結果のイメージに含めることができます。 プログラミング言語とコードによっては、バイナリー・ファイルが存在するためにいくつかのオペレーティング・システム・ファイルが必要な場合があるため、コンパイラー・オプションをさらに調整しなければならない可能性があります。
イメージをクリーンにしておく
初めて Dockerfile を開発してその動作をデバッグする場合、一時的なツールをいくつかインストールしたり、最終的なコンテナー・イメージには必要のない他のワークロードを適用したりすることがあります。 こうした一時的なファイルやワークロードを削除すれば、イメージをクリーンにし、小さくして、よりセキュアにしておくことができます。
イメージの起動時間の改善
最大限の効率を得るために、コンテナー・イメージは可能な限り速く起動する必要があります。 アプリケーションの場合、関係のあるメトリックは、HTTP エンドポイントが使用可能になり、着信 HTTP 要求を受け取って処理できる状態になるまでに要する時間です。 起動速度は、Knative に基づいた Code Engine アプリケーションには特に重要です。 このようなアプリケーションは、トラフィックがないときは実行中のインスタンスをゼロにスケールダウンするように構成できるので、リソースを消費せず、コストがかかりません。 要求が来ると、インスタンスが開始されてその要求を処理します。 コンテナーは、新しい要求に応答するために可能な限り速く起動する必要があります。
アプリケーションの起動を改善するには、アプリケーションの実装を調べて、以下のようなパターンを適用できるかどうかを確認します。
- 独立した初期化処理を並列化する。例えば、データベースへの接続を確立するための処理や、メール・サーバーと通信するための構成ファイルを環境変数から読み取るための処理などです。
- アプリケーションの起動には必要のない初期化処理を遅らせ、代わりに最初に必要になったときに行う。
さらに、Angular や React、Vue などのフレームワークを使用する Web アプリケーションを実装する際に、よくある落とし穴を回避することもできます。 これらのフレームワークはそれぞれ、NPM を含めて Node.js に基づいており、プロジェクトを簡単にセットアップするためのコマンド・ライン・インターフェースが組み込まれています。 例えば、create-react-app
コマンドで作成された React アプリケーションは、いくつかの事前定義スクリプトを含む package.json
ファイルをセットアップします。 これらのスクリプトの 1 つが start
です。このスクリプトは、Web サーバーを Web アプリケーションとともに立ち上げます。 Dockerfile は、次の例のようになります。
FROM nodejs:16-alpine
COPY . /app
WORKDIR /app
RUN npm install
EXPOSE 3000
ENTRYPOINT ["npm", "run", "start"]
このタイプの Dockerfile は機能しますが、高速ではありません。npm run start
コマンドが呼び出されると必ず、アプリケーションがコンパイルされ、その後に開始されるためです。 この遅延は、小さいサンプル・サイズを超えるアプリケーションでは特に顕著です。 正しいアプローチは、ビルド時にアプリケーションをコンパイルし、起動時にだけ提供することです。
FROM node:16-alpine AS builder
COPY . /app
WORKDIR /app
RUN npm install && npm run build
FROM node:16-alpine
RUN npm install -g serve
COPY --from=builder /app/build /app
EXPOSE 8080
ENTRYPOINT [ "serve", "--single", "--no-clipboard", "--listen", "8080", "/app" ]
この場合も、ビルダーとランタイムの 2 ステージ・パターンになっています。 更新されたサンプルでは異なるポート 8080
が使用されていることにも注意してください。 この例はその他のポートを使用しても機能しますが、8080
は Code Engine アプリケーションのデフォルト・ポートです。 さらに、コンパイル済みビルドを使用すると、node_modules
にインストールされているすべてのソースおよびツールが最終コンテナー・イメージに含まれなくなるため、サイズが
281 MB から 97 MB に削減されます。
非 root としてコンテナーを実行する
適切に設計されたシステムは、最小特権の原則に従っています。つまり、アプリケーションまたはユーザーは、特定のアクションを実行するために必要な特権だけ取得します。 Code Engineでは、通常はシステムへの管理アクセスを必要としない、アプリケーション・サーバーまたは何らかのバッチ・ロジックを実行します。 したがって、そのコンテナー内で root として実行されてはなりません。 定義済みのユーザーを使用してコンテナー・イメージをセットアップし、非 root として実行するのが良い方法です。 例えば、前のシナリオに基づくと、次のようになります。
FROM node:16-alpine AS builder
COPY . /app
WORKDIR /app
RUN npm install && npm run build
FROM node:16-alpine
RUN npm install -g serve
COPY --from=builder /app/build /app
USER 1100:1100
EXPOSE 8080
ENTRYPOINT [ "serve", "--single", "--no-clipboard", "--listen", "8080", "/app" ]
この Dockerfile は、USER
コマンドを使用して、ユーザーおよびグループ 1100 として実行されることを指定しています。 このコマンドは、指定されたユーザーとグループをコンテナー・イメージ内に暗黙的に作成するわけではないことに注意してください。 通常、この構造は受け入れられますが、アプリケーション・ロジックでユーザーとそのホーム・ディレクトリーも存在する必要がある場合は、ユーザーとグループを明示的に作成しなければなりません。
FROM node:16-alpine AS builder
COPY . /app
WORKDIR /app
RUN npm install && npm run build
FROM node:16-alpine
RUN npm install -g serve && \
addgroup nonroot --gid 1100 && \
adduser nonroot --ingroup nonroot --uid 1100 --home /home/nonroot --disabled-password
COPY --from=builder /app/build /app
USER 1100:1100
EXPOSE 8080
ENTRYPOINT [ "serve", "--single", "--no-clipboard", "--listen", "8080", "/app" ]
グループを作成し、ユーザーをホーム・ディレクトリーとともに作成するために、ランタイム・ステージの RUN
コマンドが拡張され、addgroup
コマンドと adduser
コマンドを呼び出しています。