Dockerの基礎:image、container、layer

1. 概要

Docker初心者の多くがimageとcontainerを混同したり、なぜ2回目のdocker buildがそんなに速いのかを理解していません。この記事では3つの基本概念を説明します:image、container、layer — 実際のSpring Bootアプリケーション例付き。この記事を読み終わったら、自分で合理的なDockerfileを書くことができるようになり、layer キャッシュを活用してビルドを高速化する方法を知ることができ、Javaアプリケーションをコンテナ化する際によくあるエラーを避けられるようになります。

2. Imageとは何か

Imageは読み取り専用のスナップショットで、アプリケーションを実行するのに必要なすべてのものを含みます:基本OS、ランタイム(例えば JVM)、ライブラリ、環境変数、そしてあなたのコード。Image は自分では実行されません — テンプレートのようなもので、そこからcontainerが作成されます。

Dockerfileを使ってimageを定義します:

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

各行の説明:

  • FROM:ベースimageを選択(Temurin上の JRE 21)。その後に追加されたすべてのビルド内容はこの上に積み重なります。
  • WORKDIR:container内の作業ディレクトリを設定します。その後のコマンドはこのディレクトリを基準に実行されます。
  • COPY:あらかじめビルドされたjarファイル(mvn packageから)をimageにコピーします。
  • ENTRYPOINT:containerが起動するときに実行されるコマンド。

次のコマンドでimageをビルドします:

docker build -t order-service:1.0 .

結果:

[+] Building 3.2s (8/8) FINISHED
 => [1/3] FROM eclipse-temurin:21-jre
 => [2/3] WORKDIR /app
 => [3/3] COPY target/order-service.jar app.jar
 => exporting to image
 => => naming to docker.io/library/order-service:1.0

このimageはローカルに保存されます。次のコマンドで確認できます:docker images。

注意: Image は静的なファイルに過ぎません。同じDockerfileとコードで何度もdocker buildを実行しても実行中のアプリケーションは作成されません — 実行するには、imageからcontainerを作成する必要があります。

3. Containerとは何か

Containerは実行中(または停止中)のinstanceで、imageから作成されます。imageが「class」であれば、containerは「object」です — 同じimageから複数のcontainerを作成できます。各containerはプロセス、ネットワーク、書き込み可能なファイルシステムレイヤーで独立しています。

Containerを作成して実行する:

docker run -d --name order-svc -p 8080:8080 order-service:1.0
  • -d:デタッチモードで実行(バックグラウンド)。
  • --name:containerに名前を付けて管理しやすくします。ランダムなIDの代わりに。
  • -p 8080:8080:ホストのポート8080をcontainer内のポート8080にマッピング。

実行中のcontainerを確認:

docker ps
CONTAINER ID   IMAGE                  STATUS         PORTS                    NAMES
3f2a9c1b8d4e   order-service:1.0      Up 5 seconds   0.0.0.0:8080->8080/tcp   order-svc

同じimageから2番目のcontainerを実行することもできます。名前とポートを変更するだけ:

docker run -d --name order-svc-2 -p 8081:8080 order-service:1.0

これらの2つのcontainerは完全に独立しています:1つのcontainerがクラッシュしたり、そのファイルシステムにファイルを書き込んだりしても、他のcontainerは影響を受けません。元のimage(order-service:1.0)は変わりません — これがimageの読み取り専用の本質です。

不要になったcontainerを停止して削除:

docker stop order-svc && docker rm order-svc

3.1. ContainerとVirtual Machine(VM)の違い

Containerはホストとカーネルを共有します。VMのようにOS全体を仮想化しません。そのため、containerは数秒で起動し、VMのように数十秒かかりません。リソース消費量ははるかに少なくなります。

4. Layerとは何か、なぜ重要か

Dockerfile内の各コマンド(FROM、COPY、RUN、ADDなど)は固有のlayerを作成します。これらのlayerは積み重なり(union filesystem)、Dockerは各layerをキャッシュします — layerが build前のlayerと変わっていない場合、Dockerはキャッシュを再利用します。

最適化されていないDockerfileを考えてみます:

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
ENTRYPOINT ["java", "-jar", "target/order-service.jar"]

問題:COPY . .はソースコード全体をコピーします。1行のコメント変更だけの場合でも。COPY layerが変更されるため、後続のRUN mvn packageのlayerを再度ビルドする必要があります — 毎回数分かかります。ロジックが変わっていなくても。

最適化するには、依存関係の部分(変更頻度が低い)を分離して、コード部分(変更頻度が高い)を後ろに配置:

FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/order-service.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

説明:

  • COPY pom.xml .の後にRUN mvn dependency:go-offline:このlayerは、pom.xmlで依存関係を追加/削除するときのみ変更されます。ほとんどの場合、このlayerはキャッシュされ、ライブラリをダウンロードするステップをスキップ — これは通常は最も時間がかかります。
  • COPY src ./srcは後に配置:このlayerとその後ろのlayerのみが再度ビルドされ、依存関係のlayerはキャッシュを保持。
  • マルチステージビルド(AS buildの後にFROM ... jre):最終ステージはビルド済みのjarファイルのみを取得し、JDKとMavenキャッシュを含めません — 本番用imageはかなり小さくなります。

imageのlayerを確認:

docker history order-service:1.0
IMAGE          CREATED        CREATED BY                      SIZE
a1b2c3d4e5f6   3 minutes ago  ENTRYPOINT ["java" "-jar"...]   0B
7f8e9d0c1b2a   3 minutes ago  COPY /app/target/order-ser…     45.2MB
...

各行は1つのlayerに対応し、それが追加するサイズ付き。COPY jarのlayerが大部分の容量を占める — これはアプリケーションの実際のコードが含まれているため合理的です。

4.1. Layerキャッシュが再度ビルド時にどのように機能するか

1つのJavaクラスを編集した場合、再度ビルド:

docker build -t order-service:1.1 .
 => [build 1/5] FROM eclipse-temurin:21-jdk
 => CACHED [build 2/5] COPY pom.xml .
 => CACHED [build 3/5] RUN mvn dependency:go-offline
 => [build 4/5] COPY src ./src
 => [build 5/5] RUN mvn package -DskipTests
 => [stage-1 2/2] COPY --from=build /app/target/order-service.jar app.jar

2つのCACHED行は、Dockerが依存関係をダウンロードしないことを示唆 — ビルドが明らかに高速化されます。最初のCOPY . .の書き方と比較して。

注意: Dockerはファイルの内容とコマンドの順序に基づいてキャッシュを比較します。タイムスタンプに基づきません。COPYやRUNの順序を変更すると、その点からすべてのlayerが無効化されます。内容が実質的に同じでも。

5. 自動テストで検証

serviceをコンテナ化する場合、ビルドされたimageが正しくhealthcheckを実行することを確認したい。Testcontainersライブラリを使用して、containerが起動して正しいレスポンスを返すことを確認するテストを書きます。

依存関係を追加:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.20.1</version>
    <scope>test</scope>
</dependency>

テストを書く:

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;

import static org.assertj.core.api.Assertions.assertThat;

class OrderServiceContainerTest {

    @Test
    void containerShouldRespondOnHealthEndpoint() {
        try (GenericContainer<?> container =
                new GenericContainer<>("order-service:1.0")
                        .withExposedPorts(8080)
                        .waitingFor(Wait.forHttp("/actuator/health"))) {

            container.start();

            assertThat(container.isRunning()).isTrue();
            assertThat(container.getMappedPort(8080)).isPositive();
        }
    }
}
  • GenericContainerは、image order-service:1.0から一時的なcontainerを作成し、tryブロックが終了するとき自動的にクリーンアップします。
  • waitingFor(Wait.forHttp(...)):テストはエンドポイントがhealthで2xxを返すまで待機し、containerが準備完了と見なします。race conditionを避けます。
  • getMappedPort(8080):Dockerはホスト上にランダムなポートをマッピング。テストは実際のポートを取得して、必要に応じて続行。

このテストを実行して、第2章でビルドしたimageを確認します。ビルドに成功しただけでなく、実際に実行できる意味での確認 — 多くの人がスキップするチェックステップ。

6. よくあるエラー

  • クリーンアップ時にimageとcontainerを混同: docker rmを実行してcontainerを削除しますが、imageは残っています。スペースを消費し続け。imageを削除するにはdocker rmiを使用。
  • 依存関係をインストールする前にソース全体をコピー:第4章で説明した通り、layerキャッシュの利点を失い、不要に遅いビルド。
  • ベースimageが重すぎる(例えば完全なubuntu)をJREランタイム用に使用:本番用imageが膨張し、pull/deployの時間が増加。最小限のJREimageを使用すべき(例えばeclipse-temurin:21-jreまたはそれがあれば-alpineバージョン)。
  • 新しいimageをビルドしても、タグを変更しない: latestタグを再利用するため、本番エラー時にロールバックが困難 — どのimageが実行中かが正確にわかりません。

マルチステージビルドが不要な場合: CIであらかじめjarをビルドしていれば(例えばGitHub Actions)、その後にdocker buildを実行する場合、buildステージは不要です — あらかじめあるjarファイルをCOPYするだけ。マルチステージは、Dockerがソースからコードをコンパイルしたい場合にのみ必要。

7. まとめ

Imageは静的なテンプレート、containerはそのimageから実行中のinstance、layerは積み重ねられたレイヤーです。Dockerのインテリジェントキャッシング機能により、より速くビルドできます。Dockerfile内のlayer順序を理解すること — 変更頻度が低い部分(依存関係)を先に配置し、変更頻度が高い部分(ソースコード)を後に配置することが、ビルド時間を最適化する最も簡単な方法です。この基礎から、複数のserviceをオーケストレートするDocker Composeなどのトピック、またはdistrolessベースを使用したimageサイズの最適化へ進むことができます。

dockerdevopscontainerimagespring-boot