jlinkでカスタムJREを作成しコンテナ・サイズを65%削減 - Spring Boot

Spring Boot で作成したアプリケーションをコンテナ化する際、jlink コマンドを利用してカスタム JRE を作成し、軽量なベース・イメージを利用する事で、349MB　から 105MB と大幅にコンテナのイメージ・サイズを削減する事ができます。今回はその方法を詳細に紹介します。

## 作成方法の手順

### サンプルプロジェクトの作成

Spring Starter を利用してサンプルのプロジェクトを作成します。

[Create Spring Boot App](https://start.spring.io/#!type=maven-project&language=java&platformVersion=2.7.3&packaging=jar&jvmVersion=17&groupId=com.yoshio3&artifactId=hello-sample&name=hello-sample&description=Demo%20project%20for%20Spring%20Boot&packageName=com.yoshio3&dependencies=web)

### サンプル　RESTful エンドポイントの作成

`HelloController.java` ファイルを作成し下記を実装します。

```java
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {
    
    @GetMapping("/hello")
    public String hello(){
        return "Hello World";
    }
}
```

### ソースコードのビルド

プロジェクトを作成したのち、ソースコードをビルドします。

```bash
> mvn clean package
```

プロジェクトを構築すると下記のファイルが target ディレクトリ配下に作成されます。

```bash
> ls target 
classes					hello-sample-0.0.1-SNAPSHOT.jar				maven-status
generated-sources		hello-sample-0.0.1-SNAPSHOT.jar.original	surefire-reports
generated-test-sources	maven-archiver								test-classes
```

ここで、`hello-sample-0.0.1-SNAPSHOT.jar` ファイルが Spring Boot の実行可能ファイルになります。  
下記のコマンドを実行し動作確認を行う事ができます。

```bash
java -jar hello-sample-0.0.1-SNAPSHOT.jar
```

ブラウザもしくは curl コマンドでエンドポイントに接続します。

```bash
> curl localhost:8080/hello    
Hello World
```

### カスタム JRE とコンテナ・イメージの作成

次に、作成したプロジェクトをコンテナ上で実行できるように、コンテナ・イメージを作成します。下記の `Dockerfile` を作成してください。

```dockerfile
##################################################################
# Stage 1: Create Custom Java VM for Spring Boot
##################################################################
FROM mcr.microsoft.com/openjdk/jdk:17-ubuntu AS CREATE-CUSTOM-JDK

WORKDIR /workspace/app

RUN jlink --no-header-files --no-man-pages --compress=2 --strip-debug --add-modules java.base,java.logging,java.naming,java.desktop,java.management,java.security.jgss,java.instrument,java.sql --output custom-jre

##################################################################
# Stage 2: Need Zlib for JVM on Distroless Image
##################################################################
FROM mcr.microsoft.com/cbl-mariner/base/core:2.0 AS ZLIB-INSTALLER

RUN mkdir /staging \
    && tdnf install -y --releasever=2.0 --installroot /staging zlib

##################################################################
FROM mcr.microsoft.com/cbl-mariner/distroless/minimal:2.0 AS CBLMIN

VOLUME /tmp
WORKDIR /app

ARG DEPENDENCY=/workspace/app/
COPY --from=CREATE-CUSTOM-JDK ${DEPENDENCY}/custom-jre /app/jre
COPY --from=ZLIB-INSTALLER /staging/ /

ENV LANG='ja_JP.UTF-8' LANGUAGE='ja_JP:ja' LC_ALL='ja_JP.UTF-8'
ENV TZ='Asia/Tokyo'
ENV JAVA_HOME=/app

# COPY the Spring Boot application artifact from Local
COPY ./target/hello-sample-0.0.1-SNAPSHOT.jar    app.jar

ENTRYPOINT ["/app/jre/bin/java","-jar","/app/app.jar"]
EXPOSE 8080  
```

`Dockerfile` を作成した後、下記のコマンドを実行しコンテナ・イメージを作成します。

```bash
> docker build -t tyoshio2002/hello-spring:1.0 .
```

作成したイメージのファイル・サイズを確認してください。`105MB` のイメージが作成できた事を確認できます。

```bash
> docker images                                     
REPOSITORY                  TAG    IMAGE ID       CREATED          SIZE
tyoshio2002/hello-spring    1.0    5ef97f8669d8   26 seconds ago   105MB
```

作成したコンテナ・イメージを実行してください。

```bash
> docker run -it -p 8080:8080 tyoshio2002/hello-spring:1.0

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.3)

2022-09-21 14:03:37.631  INFO 1 --- [           main] com.yoshio3.HelloSampleApplication       : Starting HelloSampleApplication v0.0.1-SNAPSHOT using Java 17.0.4.1 on ae593530aadc with PID 1 (/app/app.jar started by root in /app)
2022-09-21 14:03:37.634  INFO 1 --- [           main] com.yoshio3.HelloSampleApplication       : No active profile set, falling back to 1 default profile: "default"
2022-09-21 14:03:38.420  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2022-09-21 14:03:38.429  INFO 1 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2022-09-21 14:03:38.429  INFO 1 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.65]
2022-09-21 14:03:38.477  INFO 1 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2022-09-21 14:03:38.478  INFO 1 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 797 ms
2022-09-21 14:03:38.748  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2022-09-21 14:03:38.756  INFO 1 --- [           main] com.yoshio3.HelloSampleApplication       : Started HelloSampleApplication in 1.481 seconds (JVM running for 1.869)
```

ブラウザもしくは curl コマンドでエンドポイントに接続します。

```bash
> curl localhost:8080/hello    
Hello World
```

この `Dockerfile` を使用した方法により、`105MB` という非常に小さなコンテナ・イメージを作成できました。  
Azure 上で、小さなコンテナ・イメージを利用したい場合で、かつ Java 11 以降の環境では上記のように、jlink コマンドを使用したカスタム JRE を作成し、Microsoft が正式サポートする軽量な Linux コンテナ・イメージを作成しご利用いただく事ができます。

## 技術の詳細：この方法をおすすめする理由

以降では、なぜ上記の方法をお勧めするか、より詳しく説明します。

### OpenJDK をインストールしたコンテナ・イメージの確認

まず、OpenJDK をインストールしたコンテナ・イメージのファイル・サイズを確認します。
Microsoft が提供する MS Build OpenJDK 関連のコンテナのベース・イメージは下記より入手可能です。

* [Container images for the Microsoft Build of OpenJDK](https://learn.microsoft.com/en-us/java/openjdk/containers)

これらのイメージを実際に pull して確認すると、下記を確認できます。(2022/09/21 時点)

```bash
mcr.microsoft.com/openjdk/jdk:17-ubuntu        412MB
mcr.microsoft.com/openjdk/jdk:17-mariner       364MB
mcr.microsoft.com/openjdk/jdk:17-distroless    331MB
```

こちらをご覧いただくと、一番下の行に記載されている、Mariner の distroless のイメージ・サイズが最も小さい事がわかります。カスタム JRE を作成しない場合は、一番下のイメージをベース・イメージとしてご利用いただくのがお勧めです。

しかし、他のプログラミング言語で利用するコンテナのイメージサイズと比較して、330MB を超えるイメージは如何でしょうか？  実際には、自分が作成したアプリケーションをデプロイするため、このサイズに対しプラス・α のイメージ・サイズになります。
なぜ、このようにイメージ・サイズが大きくなっているのかを次で確認してみたいと思います。

### OpenJDK 自身のファイルサイズの確認

それでは次に、OpenJDK のファイル・サイズを確認します。
MS Build OpenJDK では下記の URL より、各プラットフォーム毎の OpenJDK を入手する事ができます。

* [Download the Microsoft Build of OpenJDK](https://learn.microsoft.com/en-us/java/openjdk/download)

こちらから、Linux 版の JDK 17.0.4.1 をダウンロードして確認しました。その結果、アーカイブされた状況で `191MB` でした。

```text
-rw-r--r--@  1 teradayoshio  staff    191382575  9 21 17:03 microsoft-jdk-17.0.4.1-linux-x64.tar.gz
```

アーカイブを展開すると、約 `324MB` になりました。この事から、distroless のイメージの大半が OpenJDK のファイル群である事がお分かりいただけるかと思います。

```bash
> du -sk .
324656 .
```

完全な OpenJDK のイメージを含んだバージョンのイメージを利用する場合、それだけで大きなサイズのコンテナ・イメージが作成される事が上記からお分かり頂けるかと思います。
もちろん、プロバイダーは src.zip を削除したり、いらないファイルを削除する事でスリム化を試みていますが、OpenJDK を完全にインストールしたイメージを利用した場合、サイズを小さくするのに限界があります。

そこで以降では、より小さなイメージを作成するための方法をご紹介します。

### jlink を利用したカスタム JRE の作成

サイズをより小さくするために、`Java 9` で導入された [jlink](https://docs.oracle.com/javase/9/tools/jlink.htm) コマンドを利用します。
jlink コマンドでカスタムな Java Runtime を作成する事で、自分のアプリケーションを動かすために必要最小限の、小さな Java 実行環境を手に入れる事ができます。

`Dockerfile` 中の `Stage 1` で、ローカルでビルドを行った Java の成果物(`./target/yoshio-sample-0.0.1-SNAPSHOT.jar`)をコピーし、`jlink` コマンドで、SpringBoot アプリケーションの実行に必要なモジュールを読み込み、カスタムな JRE(`custom-jre`) を作成しています。  
カスタム JRE は `custom-jre` ディレクトリ配下に生成されます。

```dockerfile
##################################################################
# Stage 1: Create Custom Java VM for Spring Boot
##################################################################
FROM mcr.microsoft.com/openjdk/jdk:17-ubuntu AS CREATE-CUSTOM-JDK

WORKDIR /workspace/app

RUN jlink --no-header-files --no-man-pages --compress=2 --strip-debug --add-modules java.base,java.logging,java.naming,java.desktop,java.management,java.security.jgss,java.instrument,java.sql --output custom-jre
```

> ご注意：  
>`jlink` コマンドを含む OpenJDK が必要なため、`Stage 1` では `mcr.microsoft.com/openjdk/jdk:17-ubuntu` のイメージを利用しています。実行環境は `Stage 1` で作成したカスタムな JRE をコピーして利用します。また、カスタム JRE は一度作成したら、極力再利用をしてください。Container はレイヤー構造になっており、変更のないレイヤーは再利用されます。変更のあった部分だけを更新する事で、コンテナー・イメージの Upload や Pull の容量を減らす事ができ、起動時間の短縮に繋がります。

> ご注意:
> 仮にアプリの中で SSL 通信が必要な場合は、`jdk.crypto.ec` をモジュールに追加してください。

### 最終成果物のコンテナ・イメージの作成

#### Microsoft のコンテナ環境で推奨サポートされるコンテナ・イメージ

カスタム JRE を作成したので最後に、サービスとして提供するコンテナ・イメージを作成します。
最終的な実行環境のベース・コンテナー・イメージとして利用するのは、マイクロソフトが正式にサポートする、[CBL-Mariner](https://microsoft.github.io/CBL-Mariner/docs/) というイメージを利用します。

CBL-Mariner Linux は、クラウド環境に必要なパッケージだけを含んだ軽量でセキュアな OS で、アプリケーションの要件に合わせてカスタマイズも可能です。  
CBL-Mariner は、Microsoft クラウド サービスおよび関連製品で使用するために推奨されている Linux ディストリビューションで、Microsoft Build OpenJDK を含むコンテナ・イメージでもベース・イメージとして採用されています。

現在 CBL-Mariner は 2.0 がリリースされており "[Announcing CBL-Mariner 2.0](https://microsoft.github.io/CBL-Mariner/announcing-mariner-2.0/)"こちらのバージョンを利用する事が推奨されています。  

> ご注意  
> 古い Mariner 1.0 は、Mariner 2.0 の正式リリース後、約6か月間は維持されますが、Mariner 2.0 の使用を推奨します。

コンテナ・イメージは下記から Pull して利用できます。

* Base container
    - mcr.microsoft.com/cbl-mariner/base/core:2.0

* Distroless containers
    - mcr.microsoft.com/cbl-mariner/distroless/base:2.0
    - mcr.microsoft.com/cbl-mariner/distroless/debug:2.0
    - mcr.microsoft.com/cbl-mariner/distroless/minimal:2.0

実際にそれぞれの・イメージを入手してイメージ・サイズを比較した結果が下記です。

```bash
mcr.microsoft.com/cbl-mariner/base/core:2.0            66.1MB
mcr.microsoft.com/cbl-mariner/distroless/base:2.0      31.1MB
mcr.microsoft.com/cbl-mariner/distroless/debug:2.0     32.6MB
mcr.microsoft.com/cbl-mariner/distroless/minimal 2.0   203kB
```

これらのイメージの中から、本番環境で利用するコンテナ・イメージを選択します。

#### コンテナ・イメージの作成

最後に、最終成果物のコンテナ・イメージを作成します。`Stage 3`が最終的なサービス提供用のコンテナ・イメージです。しかし Stage 3 の前に、`Stage 2` を行わないと `zlib` 関連のファイルが存在しないためエラーが発生します。そこでカスタム JRE の実行ができません。そこで、`Stage 2` で `zlib` を入手し、`Stage 3` のイメージにコピーしています。  これにより不要なファイルをインストールせずに、必要なライブラリだけを `Stage 3` のイメージにコピーして作成する事ができます。

```dockerfile
##################################################################
# Stage 2: Need Zlib for JVM on Distroless Image
##################################################################
FROM mcr.microsoft.com/cbl-mariner/base/core:2.0 AS ZLIB-INSTALLER

RUN mkdir /staging \
    && tdnf install -y --releasever=2.0 --installroot /staging zlib

##################################################################
FROM mcr.microsoft.com/cbl-mariner/distroless/minimal:2.0 AS CBLMIN

VOLUME /tmp
WORKDIR /app

ARG DEPENDENCY=/workspace/app/
COPY --from=CREATE-CUSTOM-JDK ${DEPENDENCY}/custom-jre /app/jre
COPY --from=ZLIB-INSTALLER /staging/ /

ENV LANG='ja_JP.UTF-8' LANGUAGE='ja_JP:ja' LC_ALL='ja_JP.UTF-8'
ENV TZ='Asia/Tokyo'
ENV JAVA_HOME=/app

# COPY the Spring Boot application artifact from Local
COPY ./target/hello-sample-0.0.1-SNAPSHOT.jar    app.jar

ENTRYPOINT ["/app/jre/bin/java","-jar","/app/app.jar"]
EXPOSE 8080 
```

最終的に、`Stage 1`で作成した、カスタム JRE、`Stage 2` の zlib をコピーし、ロケールやタイムゾーンを設定し最後にビルドしたアプリケーションの jar ファイル(`app.jar`)をコピーして起動しています。

こちらの方法は、[MS Build OpenJDK の提供イメージを参考](https://github.com/microsoft/openjdk-docker/blob/main/docker/distroless/Dockerfile.msopenjdk-17-jdk)にして作成した方法です。
カスタム JRE を利用したより軽量なサイズのコンテナ・イメージを作成する方法として、開発チームにも確認をしサポートされる内容ですので、どうぞ参考にしてご活用ください。