本記事には広告(アフィリエイトリンク)が含まれます。

Dockerfileマルチステージ|CKAD第3回

広告

新卒インフラエンジニア向け「Kubernetes 実践教科書① CKAD アプリケーション開発編」の第3回です。第2回までで Docker の基本操作を身につけました。今回は Dockerfile を書いて、本シリーズを通して育てる模擬アプリ fanclub-api の Backend イメージを自分でビルドします。Java(JDK 25 + Payara Micro)アプリを、マルチステージビルドで無駄のない実行イメージに仕上げ、JVM ヒープとコンテナのメモリ制限の関係(Container-aware JVM)まで押さえます。

動作確認バージョン:AlmaLinux 10.2 / Docker CE 29.6.0 / JDK 25(eclipse-temurin)/ Maven 3.9 / Payara Micro 7.2026.4(2026-06-22 時点)

広告

今ここマップ(全 19 回中の現在地)

現在地は第 1 部「コンテナと Docker」の第 3 回です。ここで模擬アプリ fanclub-api の Backend が初めて登場します。

  • 第 1 部 コンテナと Docker(第 1〜4 回)← 今ここ
  • 第 2 部 Kubernetes 基礎(第 5〜6 回)
  • 第 3 部 アプリリソース(第 7〜11 回)
  • 第 4 部 ワークロード戦略(第 12〜14 回)
  • 第 5 部 セキュリティ基礎(第 15〜16 回)
  • 第 6 部 パッケージ管理と HTTPS 公開(第 17〜19 回)

この回のゴール

  • Dockerfile を作成し、マルチステージビルドで Java + Payara Micro イメージをビルドできる。
  • マルチステージビルドが「ビルド用の重い道具を実行イメージに残さない」仕組みだと説明できる。
  • JVM ヒープとコンテナの limits.memory の関係(Container-aware JVM)を理解できる。

模擬アプリ fanclub-api と Backend のコード

fanclub-api は会員管理を行う 3 層構成のアプリです。Frontend(Nginx)・Backend(Java REST API)・Database(PostgreSQL)から成り、本シリーズを通じて段階的に組み上げます。今回はその中核となる Backend(JAX-RS による REST API + MicroProfile Health)をビルドします。

Backend の Maven プロジェクト構成は次のとおりです。

fanclub-api/
├── pom.xml
├── Dockerfile
├── settings.xml           # Maven のプロキシ設定(ビルドステージで使用)
└── src/main/java/com/example/fanclub/
    ├── AppConfig.java         # JAX-RS 有効化(@ApplicationPath("/api"))
    ├── Member.java            # JPA エンティティ(members テーブル)
    ├── MembersResource.java   # JAX-RS エンドポイント(CRUD)
    ├── MembersService.java    # ビジネスロジック
    ├── DbConfig.java          # EntityManagerFactory を環境変数から構築(CDI)
    ├── FanclubDb.java         # CDI 修飾子(EntityManagerFactory の一意化)
    └── HealthChecks.java      # MicroProfile Health(live / ready / started)

pom.xml の要点です。Jakarta EE 11 と MicroProfile は実行時に Payara Micro が提供するため provided、PostgreSQL JDBC ドライバは WAR に同梱するため compile とします。パッケージングは war。web.xml を持たない構成のため、maven-war-pluginfailOnMissingWebXmlfalse にしてビルドを通します。

<packaging>war</packaging>

<properties>
  <maven.compiler.release>25</maven.compiler.release>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
  <dependency>
    <groupId>jakarta.platform</groupId>
    <artifactId>jakarta.jakartaee-web-api</artifactId>
    <version>11.0.0</version>
    <scope>provided</scope>
  </dependency>
  <dependency>
    <groupId>org.eclipse.microprofile</groupId>
    <artifactId>microprofile</artifactId>
    <version>6.1</version>
    <type>pom</type>
    <scope>provided</scope>
  </dependency>
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.7.4</version>
  </dependency>
</dependencies>

<build>
  <finalName>fanclub-api</finalName>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-war-plugin</artifactId>
      <version>3.4.0</version>
      <configuration>
        <failOnMissingWebXml>false</failOnMissingWebXml>
      </configuration>
    </plugin>
  </plugins>
</build>

REST エンドポイント(MembersResource.java)は会員の CRUD を担います。ベースパスは /api、リソースパスは /members です。抜粋を示します(PUT / DELETE / search も同じ形です)。

@Path("/members")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class MembersResource {

    @Inject
    private MembersService service;

    @GET
    public Response list() {
        return Response.ok(service.findAll()).build();
    }

    @POST
    public Response create(Member input) {
        Member created = service.create(input);
        return Response.status(Response.Status.CREATED).entity(created).build();
    }

    @GET
    @Path("/{id}")
    public Response get(@PathParam("id") Long id) {
        return service.findById(id)
                      .map(m -> Response.ok(m).build())
                      .orElseGet(() -> Response.status(404).build());
    }
}

ヘルスチェック(HealthChecks.java)は MicroProfile Health で 3 種類(liveness / readiness / startup)を公開します。@Liveness / @Readiness / @Startup を付けるだけで、Payara Micro が /health/live/health/ready/health/started と、それらを集約した /health のエンドポイントを自動公開します(ルーティングを書く必要はありません)。これは第12回で扱う Kubernetes の 3 つの Probe(liveness / readiness / startup)にそのまま対応します。Liveness は「プロセスが生きていれば常に UP」、Readiness は「DB に疎通できるときだけ UP」、Startup は「起動処理が完了したら UP」とするのが定石です。次に liveness と readiness の例を示します(startup も同じ書き方で、完全なソースに含まれます)。

@Liveness
@ApplicationScoped
class LivenessCheck implements HealthCheck {
    public HealthCheckResponse call() {
        return HealthCheckResponse.up("fanclub-api-live");
    }
}

@Readiness
@ApplicationScoped
class ReadinessCheck implements HealthCheck {
    @Inject private MembersService service;
    public HealthCheckResponse call() {
        boolean dbUp;
        try { dbUp = service.isDatabaseReachable(); }
        catch (RuntimeException e) { dbUp = false; }
        return HealthCheckResponse.named("fanclub-api-ready").status(dbUp).build();
    }
}

残りのファイル(Member.java のエンティティ、MembersService.java のビジネスロジック、DbConfig.java の EntityManagerFactory 構築)を含む完全なソースは、本シリーズの教材一式に同梱しています。本記事ではビルドに集中するため、ここからは Dockerfile に話を進めます。

Dockerfile の基本記法

Dockerfile はイメージの作り方を記述するテキストファイルです。主要な命令を押さえます。

  • FROM:ベースイメージを指定する(イメージの出発点)。
  • WORKDIR:以降の作業ディレクトリを設定する。
  • COPY / ADD:ファイルをイメージに取り込む(ADD は URL 取得や展開も可能)。
  • RUN:ビルド時にコマンドを実行する(各 RUN が 1 レイヤーになる)。
  • ENV:環境変数を設定する。
  • EXPOSE:コンテナが待ち受けるポートを宣言する(ドキュメント目的)。
  • ENTRYPOINT / CMD:コンテナ起動時に実行するコマンドを指定する。

各命令はイメージの「レイヤー」を作り、Docker はレイヤー単位でビルド結果をキャッシュします。変更の少ないものを先に、変更の多いものを後に COPY すると、キャッシュが効いてビルドが速くなります。後述の Dockerfile で pom.xml を先にコピーして依存解決し、ソースを後からコピーしているのはこのためです。

マルチステージビルド

Java アプリのビルドには Maven と JDK が必要ですが、実行に必要なのは JRE と成果物の WAR だけです。ビルド用の道具を実行イメージに残すとサイズが膨らみ、攻撃対象も増えます。そこで マルチステージビルドを使い、「ビルド用ステージ」で WAR を作り、「実行用ステージ」にはその成果物だけをコピーします。

マルチステージビルドで、ビルドステージ(Maven と JDK 25)が WAR を作り、COPY --from=build で実行ステージ(JRE のみ)へ成果物だけを渡す仕組みの図。ビルド用の道具は実行イメージに残らない。
図1:マルチステージビルドは道具を捨て成果物だけ運ぶ

fanclub-api Backend の Dockerfile(第3回時点)です。

# ===== ビルドステージ =====
FROM maven:3.9-eclipse-temurin-25 AS build
COPY settings.xml /root/.m2/settings.xml
WORKDIR /app
COPY pom.xml .
RUN mvn -B --no-transfer-progress dependency:go-offline
COPY src ./src
RUN mvn -B --no-transfer-progress package

# ===== 実行ステージ =====
FROM eclipse-temurin:25-jre AS runtime
WORKDIR /opt/payara
ADD https://repo.maven.apache.org/maven2/fish/payara/extras/payara-micro/7.2026.4/payara-micro-7.2026.4.jar payara-micro.jar
COPY --from=build /app/target/fanclub-api.war app.war
EXPOSE 8080
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar payara-micro.jar --deploy app.war --port 8080 --contextRoot /"]
  • ビルドステージは maven:3.9-eclipse-temurin-25(Maven と JDK 25 を同梱)。COPY --from=build で成果物だけを実行ステージへ渡します。
  • 実行ステージは eclipse-temurin:25-jre(JRE のみで軽量)。
  • Payara Micro 本体は Maven Central から取得します(nexus.payara.fish は 7.2026.x が HTTP 404 のため使用しません)。
  • --contextRoot / によりアプリがルートに配置され、/api/members/health/live が有効になります。
  • COPY --from=build /app/target/fanclub-api.warfanclub-api.war という名前は、pom.xml<finalName>fanclub-api</finalName> から決まります(finalName を変えると、この COPY のパスも合わせて変える必要があります)。
  • ENTRYPOINT["sh", "-c", "java $JAVA_OPTS …"]sh -c で包んでいるのは、環境変数 $JAVA_OPTS を展開させるためです。["java", …] という exec 形式のままだと $JAVA_OPTS はシェルを通らず、変数が展開されません。

ビルドステージ先頭の COPY settings.xml /root/.m2/settings.xml は Maven のプロキシ設定です。本シリーズの検証環境はプロキシ経由で外部接続するため、Maven の依存取得にプロキシが要ります。ここが要注意点で、Maven は JVM の -Dhttp.proxyHost プロパティを既定では使わず、settings.xml<proxies> を参照します。次の settings.xml をプロジェクト直下に置き、ビルドステージへコピーします(Maven Central は HTTPS のため protocolhttps の proxy が要点)。

<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0">
  <proxies>
    <proxy>
      <id>corp-https</id>
      <active>true</active>
      <protocol>https</protocol>
      <host>alma-proxy</host>
      <port>3128</port>
      <nonProxyHosts>localhost|127.0.0.1</nonProxyHosts>
    </proxy>
    <proxy>
      <id>corp-http</id>
      <active>true</active>
      <protocol>http</protocol>
      <host>alma-proxy</host>
      <port>3128</port>
      <nonProxyHosts>localhost|127.0.0.1</nonProxyHosts>
    </proxy>
  </proxies>
</settings>

<host> は自環境のプロキシのホスト名または IP に置き換えます(本シリーズでは第1回で用意した alma-proxy。名前解決できない環境では IP アドレスを直接指定します)。プロキシの無い環境では <proxies> を空にした <settings/> を置けば構いません。なお Payara 本体の ADD による取得とベースイメージの pull は、第1回で設定した Docker デーモンのプロキシ経由で行われます(Maven の settings.xml とは別系統です)。

補足:この Dockerfile は root で実行する最小構成です。非 root 実行や読み取り専用ルートファイルシステム(readOnlyRootFilesystem)への対応は、セキュリティを扱う第15回で追加し、その時点でイメージを v1.0.0 に更新します。本回のイメージタグは v0.1.0 です。

JDK 25 LTS と Container-aware JVM

本シリーズは 2026 年の新しい LTS である JDK 25 を採用します。JVM はかつてホストの物理メモリを基準にヒープを決めていたため、コンテナのメモリ制限を無視して確保し、limits.memory 超過で OOMKilled になる事故が起きていました。JDK 11 以降は Container-aware JVM-XX:+UseContainerSupport が既定で有効)となり、コンテナの cgroup メモリ制限を自動検出してヒープを計算します。

Dockerfile の JAVA_OPTS="-XX:MaxRAMPercentage=75.0" は「制限値の 75% までをヒープ上限にする」設定です。たとえばコンテナの limits.memory が 512Mi なら、ヒープ上限は約 384Mi に収まります。残り 25% は Metaspace やスレッドスタックなどに使われます。この設定により、Kubernetes でメモリ制限を付けたときに OOMKilled を避けやすくなります(第7回で実際に OOMKilled を起こして確認します)。

コンテナの limits.memory 512Mi に対し、MaxRAMPercentage=75.0 で JVM ヒープ上限が約 384Mi(75%)、残り約 128Mi(25%)が Metaspace 等に割り当てられることを示す図。
図2:Container-aware JVM とメモリ制限の関係

ビルドと起動確認

イメージをビルドします。Dockerfilesettings.xmlpom.xmlsrc を置いた fanclub-api/ ディレクトリに移動し、そこで次のコマンドを実行します。末尾の .ビルドコンテキスト(=カレントディレクトリ)を指し、ここに含まれるファイルだけが COPY の対象になります。Maven のプロキシは前述の settings.xml で設定済みのため、ビルドコマンドはシンプルです。タグは fanclub-backend:0.1.0 とします。

実行コマンド:

$ docker build -t fanclub-backend:0.1.0 .

ビルド中、Maven 起動時に WARNING: ... restricted method in java.lang.System という警告が出ることがありますが、これは JDK 25 の Project Panama 関連の警告で動作には影響しません

ビルドしたイメージを起動します。Backend は 8080 番で待ち受けます。

実行コマンド:

$ docker run -d --name backend -p 8080:8080 fanclub-backend:0.1.0

Payara Micro の起動には数秒かかります(実測でアプリのデプロイに約 2.6 秒、起動からヘルスチェックに応答できるまで合わせて 6〜7 秒前後)。起動後、Liveness のヘルスチェックを叩いて確認します。この時点では DB を用意していないため Readiness(/health/ready)は DOWN ですが、Liveness(/health/live)はプロセスが生きていれば UP を返します。

実行コマンド:

$ curl -s http://localhost:8080/health/live

実行結果(例):

{"status":"UP","checks":[{"name":"fanclub-api-live","status":"UP","data":{}}]}

"status":"UP" が返れば、マルチステージビルドで作った Backend イメージが正しく起動しています。これで模擬アプリ Backend の v0.1.0 がローカルに揃いました。

やってみよう

  1. fanclub-api の Backend ソース一式(pom.xml / src / Dockerfile / settings.xml)を fanclub-api/ ディレクトリに用意する。
  2. その fanclub-api/ へ移動し docker build -t fanclub-backend:0.1.0 . でビルドする(プロキシ環境では settings.xml<proxies> を設定しておく)。
  3. docker images でビルドしたイメージのサイズを確認する(実行ステージが JRE のみで軽量なことを確認)。
  4. docker run -d --name backend -p 8080:8080 fanclub-backend:0.1.0 で起動し、curl http://localhost:8080/health/liveUP を返すことを確認する。
  5. docker logs backend で Payara Micro の起動ログを確認する。

理解度チェック

次の各文が正しいか(○)誤りか(×)を判断してください。解答は下にまとめています。

  1. マルチステージビルドは、ビルド用の道具を実行イメージに残さないことでイメージを小さくできる。
  2. COPY --from=build は、別ステージの成果物を現在のステージに取り込む。
  3. 変更の多いソースを先に、変更の少ない pom.xml を後に COPY するとビルドキャッシュが効きやすい。
  4. JDK 11 以降は -XX:+UseContainerSupport が既定で有効で、コンテナのメモリ制限を検出する。
  5. -XX:MaxRAMPercentage=75.0 はヒープ上限をメモリ制限の 75% に抑える設定である。
  6. Payara Micro は nexus.payara.fish から取得するのが本シリーズの正しい方法である。
  7. DB が無い状態でも Liveness(/health/live)は UP を返す設計である。

解答

  • 1. ○:実行ステージには JRE と WAR だけを置くため軽量・安全になる。
  • 2. ○:--from=<ステージ名> で別ステージの成果物をコピーする。
  • 3. ×:逆。変更の少ない pom.xml を先にコピーして依存解決をキャッシュし、変更の多いソースを後にする。
  • 4. ○:Container-aware JVM により cgroup のメモリ制限を基準にヒープを計算する。
  • 5. ○:制限値の 75% をヒープ上限とし、残りを Metaspace 等に充てる。
  • 6. ×:nexus.payara.fish は 7.2026.x が 404。Maven Central から取得する。
  • 7. ○:Liveness はプロセス生存のみを見る。DB 障害で再起動ループに陥らせない設計。

まとめ

本記事では、模擬アプリ fanclub-api の Backend を題材に、Dockerfile の基本記法とマルチステージビルドを学び、JDK 25 + Payara Micro の実行イメージ fanclub-backend:0.1.0 をビルドしました。Container-aware JVM とヒープ比率の設定は、後の Kubernetes でメモリ制限を付ける際の土台になります。MicroProfile Health の 3 種チェックは、第12回の 3 つの Probe に直結します。

次回予告

次回(第4回)は、ビルドしたイメージを保管するコンテナレジストリを構築します。fanclub-backend イメージを k8s-registry に push し、イメージタグ戦略(latest の罠・セマンティックバージョニング)と、Trivy による脆弱性スキャンを扱います。

シリーズ一覧

第1部:コンテナと Docker

第2部:Kubernetes 基礎

第3部:アプリリソース

第4部:ワークロード戦略

第5部:セキュリティ基礎

第6部:パッケージ管理 + HTTPS 公開

広告
kubernetes
スポンサーリンク