Dockerize Spring Boot: 800MBの「巨大」イメージを150MBに削減し、OOMエラーを完封する秘策

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

「無邪気な」JavaアプリのDocker化が招く悲劇

Javaエンジニアの皆さんなら、Spring BootのJAR fileが数百MBにも膨れ上がり、顔をしかめた経験が一度や二度はあるはずです。さらに悪いことに、それを標準的なDockerイメージに詰め込むと、サイズは数GBにまで跳ね上がることがあります。私も「駆け出し」の頃は、単純にopenjdk:17イメージを選んでJARファイルをCOPYするだけで済ませていました。その結果は?イメージは肥大化し、ビルドは遅く、本番環境(Production)では原因不明のコンテナ「突然死」(OOM:メモリ不足)が頻発しました。

かつて私はAWS上で30以上のマイクロサービスを稼働させるクラスターを管理していました。Dockerfileの最適化を怠ったことで、ECRのストレージコストとRAMの使用量が大幅に膨らんでしまいました。しかし、Multi-stage Buildの導入とJVMの微調整を行った結果、リソース消費を40%削減することに成功しました。具体的には、各インスタンスのRAM使用量を512MBから約300MBまで抑えつつ、アプリの動作は以前よりもスムーズになりました。この記事は、そんな苦い経験から得た教訓をまとめたチェックリストです。

なぜ、あなたのイメージはこれほど「肥満」なのか?

最大の罠は、ビルド環境をそのまま実行環境(Runtime)に持ち込んでしまうことです。JARファイルを実行するだけならJREがあれば十分です。しかし、ビルドのためにJDK、Maven、ソースコード、そして.m2ディレクトリ内の大量の不要なライブラリまで引き連れてしまっています。これはリソースの無駄遣いであるだけでなく、不必要な攻撃面(Attack Surface)を作り出し、セキュリティ上の脆弱性にもつながります。

Multi-stage Buildは、この問題を「2段階のプロセス」に分けることで解決します:

  • 第1段階(ビルド): ツールが揃ったフル機能のイメージを使用して、コンパイルとパッケージングを行います。
  • 第2段階(実行): 生成されたJARファイルだけを、非常に軽量なJREイメージにコピーして実行します。

その結果、最終的なイメージは非常にコンパクトになり、プッシュやプルの際の帯域幅を節約し、デプロイ速度を劇的に向上させることができます。

実践:Spring Bootプロジェクト向けの「正解」Dockerfile

以下は、私がMavenプロジェクトで常用しているDockerfileのテンプレートです。Gradleを使用している場合は、ビルドコマンドを適宜読み替えてください。

# 第1段階:ビルドステージ
FROM maven:3.8.4-openjdk-17-slim AS build
WORKDIR /app

# レイヤーキャッシュの最適化:先に依存関係をダウンロード
COPY pom.xml .
RUN mvn dependency:go-offline -B

# ソースコードをコピーしてビルド
COPY src ./src
RUN mvn package -DskipTests

# 第2段階:実行ステージ
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

# セキュリティ向上のため、非rootユーザーを作成
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring

# ビルドステージからJARファイルをコピー
COPY --from=build /app/target/*.jar app.jar

# コンテナ向けに最適化されたJVM設定
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport -Djava.security.egd=file:/dev/./urandom"

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

採用したテクニックの解説:

  1. レイヤーキャッシュ(6-7行目): pom.xmlを個別に扱うことで、Dockerがダウンロード済みのライブラリをキャッシュできるようになります。ライブラリに変更がなくコードの修正だけなら、このステップはスキップされ、ビルド速度が5〜10倍向上します。
  2. Alpineイメージ(13行目): alpineベースのイメージを使用することで、従来のUbuntuベースでは600MBほどあったサイズを約120MBまで軽量化できます。
  3. 非rootユーザー(17-18行目): デフォルトではDockerはroot権限で実行されます。万が一アプリに脆弱性があった場合、ハッカーにコンテナの制御権を奪われるリスクがあります。springユーザーを使用することで、被害の範囲を最小限に抑えられます
  4. JVMチューニング(24行目): これが本番環境でOOM Killer(強制終了)を防ぐための「鍵」となります。

JVMにDockerの命令を「無視」させるな

よくある間違いは、-Xmx1gのようにRAMの最大値を固定で設定してしまうことです。コンテナ環境では、これは非常にリスクが高い行為です。コンテナに1GBのRAM(--memory=1g)を割り当て、JVMも1GB使うように設定すると、アプリは間違いなくOSによって強制終了されます。なぜなら、JVMはヒープ以外にもMetaspace、Stack、Native Memoryなどの領域でメモリを必要とするからです。

私が推奨する「黄金」のパラメータは以下の通りです:

  • -XX:+UseContainerSupport:JVMがDockerのcgroupsからのリソース制限を正確に認識できるようにします。
  • -XX:MaxRAMPercentage=75.0:メモリ量を固定する代わりに、割り当てられたRAMの最大75%をJVMが使用するように指定します。残りの25%はOSやその他の補助コンポーネントのために残しておきます。これは多くの実地テストで導き出された最も安全な数値です。
  • -Djava.security.egd=file:/dev/./urandom:Linux上での乱数生成速度を向上させ、Spring Boot의起動時間を短縮します。

事実は雄弁に語る

この一連の対策を導入した後、システムは劇的に改善されました:

  • イメージサイズ: 650MBから155MBへと劇的な削減に成功。
  • ビルド時間: キャッシュの有効活用により、以前は4分以上かかっていたビルドが、2回目以降はわずか40秒に短縮。
  • 安定性: 深夜2時に突然コンテナが再起動するといったトラブルがほぼ完全に解消されました。

数百MBのRAMを節約することは些細なことのように思えるかもしれませんが、100個のコンテナを運用する規模になれば、年間で数千ドルのインフラコスト削減につながります。これこそが、単なるデベロッパーと真のエンジニアの差です。

まとめ

Spring BootをDocker化することは、単に動くようにコマンドを書くことではありません。それは、イメージサイズの最適化とインテリジェントなJVM設定を組み合わせる技術です。定期的にdocker statsを使用してコンテナの健康状態をチェックすることを忘れないでください。余裕があれば、Prometheusを導入してアプリのパフォーマンスをより包括的に把握することをお勧めします。皆さんの最適化の成功を祈っています!

Share: