7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaアプリケーションをDistrolessイメージに移行した話 - セキュリティ向上と暖気処理の内蔵化

Last updated at Posted at 2025-12-23

はじめに

Kubernetesクラスタのセキュリティ向上のため、Kubernetesが公式に提供するSecurity Checklistに準拠した対策を実施しています。
今回は以下の3つの領域に焦点を当てました。

  • Pod Security: Pod Security Standards (PSS) への準拠
  • Network Security: Network Policyによる通信制御
  • Image Security: コンテナイメージの最小化と脆弱性管理

本記事では、この中からImage Securityの取り組み、特にSpring BootアプリケーションのDistrolessイメージへの移行について解説します。

なぜDistrolessに移行するのか

従来の実行環境では、debian:stable-slimベースのイメージを使用していました。このイメージには、アプリケーション実行に必要となるJavaランタイムに加えて、以下のようなツールが含まれていました。

  • シェル(bash、sh)
  • パッケージマネージャー(apt、dpkg)
  • 各種UNIXコマンド(curl、wget、find等)

一見便利に思えますが、セキュリティの観点ではリスクにもなり得ます。

問題点

  • 攻撃面の拡大: 攻撃者がコンテナに侵入した場合、これらのツールを悪用してシステムの探索や横展開(Lateral Movement)が可能になる
  • 脆弱性の増加: 不要なパッケージが多いほど、CVE(Common Vulnerabilities and Exposures)の対象となる可能性が高まる
  • イメージサイズの肥大化: 不要なツールがディスク容量を圧迫

Distrolessへの移行

Distroless イメージは、Googleが提供するアプリケーション実行に必要最小限のコンポーネントだけを含むイメージです。シェルやパッケージマネージャーを含まず、攻撃者が侵入しても悪用できるツールが存在しません。

具体的に、従来イメージとDistrolessイメージの構成を比較してみましょう。

従来のイメージ構成

┌─────────────────────────────────────┐
│  アプリケーション (app.jar)          │
├─────────────────────────────────────┤
│  Java Runtime (JDK/JRE)             │
├─────────────────────────────────────┤
│  Shell (bash, sh)                   │
│  Package Manager (apt, dpkg)        │
│  Utilities (curl, wget, find, etc.) │
│  Libraries & Dependencies           │
├─────────────────────────────────────┤
│  Base OS (Ubuntu Jammy)             │
└─────────────────────────────────────┘

Distrolessイメージ構成

┌─────────────────────────────────────┐
│  アプリケーション (app.jar)          │
├─────────────────────────────────────┤
│  Java Runtime (JRE only)            │
│  Minimal Libraries                  │
├─────────────────────────────────────┤
│  Minimal Base Layer                 │
│  (no shell, no package manager)     │
└─────────────────────────────────────┘

DistrolessにはJavaアプリを動かすために必要な最小限のものしか含まれていません。

この移行により、以下の効果を期待しました。

  • 攻撃面の削減: シェルが存在しないため、侵入後のコマンド実行が不可能
  • 脆弱性の低減: 含まれるパッケージ数が少ない分、CVEの対象となる可能性が低下
  • イメージサイズの最適化: コンテナのPull時間短縮、ストレージコスト削減

移行における課題

Distrolessイメージへの移行で最も大きな障壁となったのが、シェルが使えないという制約です。私たちのアプリケーションでは、シェルスクリプトを使った暖気処理が存在していたため、これを書き換える必要がありました。

暖気処理

従来の暖気処理

本番トラフィックを受ける前に 暖気処理(Warmup) を実施していました。これは、JITコンパイルやクラスローディングを事前に行い、初回リクエストのレイテンシを抑えるための重要な処理です。

暖気処理の取り組みについては、過去のテックブログ「ZOZOMATのJava(JVM)アプリケーションで行っている暖機運転の取り組み」で詳しく紹介しています。

従来はKubernetesのstartupProbeでシェルスクリプトを実行していました。このスクリプトは、Apache BenchやCurlを使って実際のAPIエンドポイントにHTTPリクエストを送信していました。

warmup.shの例

#!/usr/bin/env bash
CONCURRENCY=1
REQUESTS=1

API_HOST=localhost
API_PORT=8081

echo "Start to warmup."

ab -c ${CONCURRENCY} -n ${REQUESTS} \
   -H "Content-Type: application/json" \
   "http://${API_HOST}:${API_PORT}/api/v1/items?limit=10"

ab -c ${CONCURRENCY} -n ${REQUESTS} \
   -H "Content-Type: application/json" \
   "http://${API_HOST}:${API_PORT}/api/v1/categories"

ab -c ${CONCURRENCY} -n ${REQUESTS} \
   -H "Content-Type: application/json" \
   "http://${API_HOST}:${API_PORT}/api/v1/search"

echo "Finish to warmup."

このスクリプトをstartupProbeで実行することで、Podの起動完了前にJVMを暖気していました。

startupProbe:
  periodSeconds: 1
  timeoutSeconds: 90
  failureThreshold: 300
  exec:
    command:
      - sh
      - -c
      - bash /warmup.sh

実装方法

ここからは、実際の実装方法を解説します。Distrolessへの移行を実現するため、以下の3つを変更しました。

  1. Dockerfile変更: debian baseからDistrolessイメージへの切り替え
  2. 暖気処理のアプリ内蔵化(ApplicationRunner): シェルスクリプトをJavaで実装
  3. Kubernetesマニュフェスト修正: startupProbeの削除とJavaオプションの環境変数化

それぞれの実装内容を順に説明します。

Dockerfile変更

従来のDockerfileでは、jlinkを使ったカスタムランタイム作成や、debian上でのシェルスクリプト配置をしていました。これをDistrolessイメージに置き換えます。

Before(従来のDockerfile)

# カスタムランタイムの作成
FROM eclipse-temurin:${ECLIPSE_TEMURIN_TAG} as jre-build
RUN $JAVA_HOME/bin/jlink \
         --add-modules java.base,java.compiler,... \
         --strip-debug \
         --no-man-pages \
         --no-header-files \
         --compress=2 \
         --output /javaruntime

# 本番実行環境
FROM debian:${DEBIAN_TAG} as executor
ENV JAVA_HOME=/opt/java/openjdk
ENV PATH "${JAVA_HOME}/bin:${PATH}"
COPY --from=jre-build /javaruntime $JAVA_HOME

RUN apt-get update && apt-get upgrade -y libp11-kit0 \
  && apt-get install -yq apache2-utils curl \
  && apt-get clean \
  && rm -rf /var/lib/apt/lists/*

WORKDIR /
COPY --from=builder /sample-api/build/libs/*.jar /sample-api.jar
COPY --from=builder /dd-java-agent.jar /dd-java-agent.jar
COPY /entrypoint.sh /entrypoint.sh
COPY /warmup.sh /warmup.sh
EXPOSE 8081
CMD ["./entrypoint.sh", "./sample-api.jar"]

After(Distrolessイメージ)

# 本番の実行環境(distroless)
FROM gcr.io/distroless/java21-debian12:nonroot as executor
WORKDIR /
COPY --from=builder /sample-api/build/libs/*.jar /sample-api.jar
COPY --from=builder /dd-java-agent.jar /dd-java-agent.jar
EXPOSE 8081
CMD ["/sample-api.jar"]

変更ポイント

  • jlinkステージ削除: Distrolessには既にJavaランタイムが含まれているため不要
  • シェルスクリプト削除: entrypoint.shwarmup.shを削除
  • シンプルなCMD: DistrolessのENTRYPOINTjava -jarなので、jarファイルのパスのみ指定

暖気処理のアプリ内蔵化(ApplicationRunner)

Distroless移行では、Apache BenchやCurlといったコマンドラインツールが使えないため、HTTPリクエスト送信をJavaコードで実装する必要がありました。

従来warmup.shで実行していた暖気処理を、YAMLで設定してJavaで実行するように変更しました。Spring BootのApplicationRunnerインターフェースを使い、アプリケーション起動後(Liveness=OK、Readiness=NG)のタイミングで暖気処理を実行します。暖気対象のエンドポイントはYAMLで設定し、JavaのHTTPクライアントで並列にリクエストを送信します。

WarmupRunner.java

@Component
@Profile("!local & !test")
@EnableConfigurationProperties({WarmupProperties.class})
public class WarmupRunner implements ApplicationRunner {

  private final WarmupTask warmupTask;
  private final WarmupProperties properties;
  private static final Logger logger =
      LoggerFactory.getLogger(WarmupRunner.class);

  public WarmupRunner(WarmupTask warmupTask, WarmupProperties properties) {
    this.warmupTask = warmupTask;
    this.properties = properties;
  }

  @Override
  public void run(final ApplicationArguments args) {
    logger.info("Start to warmup.");

    try {
      CompletableFuture.allOf(
              properties.requests().stream()
                  .map(warmupTask::execute)
                  .toArray(CompletableFuture[]::new))
          .join();
      logger.info("Finish to warmup.");
    } catch (final CompletionException completionException) {
      logger.error("Failed to warmup.", completionException);
      throw new IllegalStateException(completionException);
    }
  }
}

@Profile("!local & !test")により、ローカル開発環境とテスト環境では暖気処理をスキップします。

warmup-config.yaml

暖気対象のエンドポイントをYAMLで設定します。

warmup:
  requests:
    - method: GET
      path: /api/v1/items
      contentType: application/json
    - method: POST
      path: /api/v1/items
      contentType: application/json
      body:
        name: sample-item
        price: 1000

WarmupProperties.java

YAMLの設定を読み込むためのプロパティクラスです。@ConfigurationPropertiesを使って、YAMLのwarmupセクションを自動的にJavaオブジェクトにマッピングします。

@ConfigurationProperties(prefix = "warmup")
public record WarmupProperties(List<WarmupRequest> requests) {

  public record WarmupRequest(
      String method,
      String path,
      String contentType,
      Map<String, Object> body) {}
}

WarmupRunnerはこのプロパティを注入され、設定された各リクエストを順に実行します。

Kubernetesマニュフェスト修正

暖気処理をアプリケーション内で実行するようになったため、startupProbeの設定は不要になります。

Before

startupProbe:
  periodSeconds: 1
  timeoutSeconds: 90
  failureThreshold: 300
  exec:
    command:
      - sh
      - -c
      - bash /warmup.sh

After

# startupProbeを削除

暖気処理はApplicationRunnerで実行されるため、KubernetesのstartupProbeで暖気を待つ必要がなくなりました。

環境変数への移行(JDK_JAVA_OPTIONS)

従来entrypoint.shで指定していたJavaオプションを、Kubernetesの環境変数JDK_JAVA_OPTIONSに移行します。

Before(entrypoint.sh)

#!/bin/sh
java \
  -Xmx${JAVA_HEAP_XMX} \
  -Xms${JAVA_HEAP_XMS} \
  -XX:FlightRecorderOptions=stackdepth=256 \
  -Djava.util.concurrent.ForkJoinPool.common.parallelism=${COMMON_PARALLELISM} \
  -javaagent:/dd-java-agent.jar \
  -jar ${1}

After(deployment.yaml)

spec:
  template:
    spec:
      containers:
        - name: app
          env:
            - name: JDK_JAVA_OPTIONS
              value: >-
                -Xmx512m
                -Xms512m
                -XX:FlightRecorderOptions=stackdepth=256
                -Djava.util.concurrent.ForkJoinPool.common.parallelism=4
                -javaagent:/dd-java-agent.jar

JDK_JAVA_OPTIONS環境変数は、Java 9以降でjavaコマンドのみに適用されるオプションです。シェルを経由せず、直接Javaプロセスへオプションを渡せます。

検証と結果

イメージサイズの削減

Distrolessイメージへの移行により、コンテナイメージサイズが削減されました。

イメージ サイズ 削減率
debian base 191.09 MB -
distroless 143.26 MB 約25% 削減

セキュリティの向上

Distrolessイメージには、シェルやパッケージマネージャー、各種UNIXコマンドが含まれていません。これにより以下の効果が得られました。

  • 攻撃者がコンテナに侵入しても、悪用可能なツールが存在しない
  • 不要なパッケージが存在しないため、潜在的な脆弱性が減少
  • 攻撃面が最小化され、セキュリティリスクが低減

まとめ

本記事では、Spring BootアプリケーションをDistrolessイメージに移行した取り組みについて紹介しました。

実施したこと:

  • Dockerfileをシンプル化し、Distrolessイメージに切り替え
  • シェルスクリプトで実行していた暖気処理を、ApplicationRunnerを使ってSpring Bootアプリケーション内に実装
  • Kubernetesマニュフェストと環境変数の設定を調整

得られた効果:

  • イメージサイズ約25%削減
  • セキュリティリスクの低減(攻撃面の最小化)

Distrolessイメージへの移行は、セキュリティ向上とイメージサイズ削減の両方を実現できる有効な手段です。シェルスクリプトに依存した運用から脱却する必要がありますが、ApplicationRunnerを活用することで比較的スムーズに移行できました。

同じような課題に取り組んでいる方の参考になれば幸いです。

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?