はじめに
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つを変更しました。
- Dockerfile変更: debian baseからDistrolessイメージへの切り替え
- 暖気処理のアプリ内蔵化(ApplicationRunner): シェルスクリプトをJavaで実装
- 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.sh、warmup.shを削除 -
シンプルなCMD: Distrolessの
ENTRYPOINTはjava -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を活用することで比較的スムーズに移行できました。
同じような課題に取り組んでいる方の参考になれば幸いです。