はじめに
2022年10月に開催した Oracle Cloud Hangout Cafe にて「GraalVM最新事情」というテーマでお話をしました。毎度のごとく時間が押せ押せで、今回のテーマである「OCI Functions で GraalVM ネイティブ・イメージを動かす」のパートを全速力で疾走してしまい、概要すらろくにお話しできず自分としてもモヤモヤが残ってしまったので、改めてここできめ細かな説明をしていきたいと思います。
尚、GraalVM (ざくっと言うと Java および JVM ベースの言語で書かれたアプリケーションの実行を高速化する JDK ディストリビューションで、JavaScript、Python など JVM 以外の言語の実行もサポートします) に関する一般的なお話については、以下のスライド&録画で十分キャッチアップできると思いますのでご参照下さい。
OCI Functions とは
Oracle Cloud Infrastructure で提供するするサービスのひとつで、フルマネージドでサーバレスなアプリケーション実行基盤 (FaaS) です。様々な用途で利用可能で、インフラ管理不要&自動スケールし、実行時のみの課金となります(無償利用枠もあります)。
オープンソースの Fn Project をベースにしていて、アプリケーションのアーティファクトがコンテナ・イメージとなっているのも特徴です。
OCI Functions で GraalVM ネイティブ・イメージを動かす動機
FaaS (Function as a Service) は文字通りサーバレス環境でアプリケーション・プログラムの「関数」レベルの処理を実行するためのサービスで、非常に便利で色々な用途に使えます。ただしサーバレスなだけに、いきなり「関数」を呼べるには呼べるのですが、一番最初の呼び出しの際に「関数」が入っているコンテナを実行可能な状態にする必要があって、どうしてもこれに時間がかかってしまいます。また、一定のアイドル時間が経過するとこのコンテナは一旦破棄されてしまいますので、その後の呼び出しもまた同様のことが生じます。
OCI Functions ではこの制限を緩和するために Provisioned Concurrency というオプションを提供しています。これを使うと、予め決められた数のコンテナが実行可能状態で待機していて、初回呼び出しの遅延を避けることができます。
では、これで初回起動の問題は解決か!というと、実はもう一つ考慮しておくことがあります。Java アプリケーションは起動時のウォームアップに結構な時間がかかります。初回呼び出時にフロント側で設定した応答時間に間に合わずタイムアウト処理になるようなケースがあったとして、これが OCI Functions のように一定のアイドル時間が経過する(=その後の呼び出し時に新規にインスタンスが起動する)たびに発生するのは問題です。
そこで登場するのが GraalVM のネイティブ・イメージです。Java の起動時間を劇的に短縮できます。さらに、ネイティブ・イメージにするとメモリーのフットプリントを削減することもできます。これは低コストで運用できるということを意味します。OCI Functions の場合、課金単位は 「リクエスト数 + GBメモリ・秒」ですので、実行時のメモリー設定を小さくすることで課金を抑えることができます。
OCI Functions で GraalVM ネイティブ・イメージを動かすための基礎知識
OCI Functions - Java アプリケーションの内部構造
まず、知っておかないといけないのは、単純に Javaアプリケーションをコンパイルして Docker イメージにしても OCI Functions では動作しないということです。作成された Docker イメージは、私たちからは見えない OCI Functions のコンテナ・ホスト・インスタンス上にデプロイされて、OCI Functions の流儀に従ってリクエストを受信し、処理した結果を戻します。具体的に言うと、実はリクエスト/レスポンスの送受信は Unix Domain Socket を使って行われています。そうした低レベルのインターフェースを隠蔽して、業務アプリケーション側に高レベルAPIを提供しているのが Function Development Kit (FDK) です。Java アプリケーション用には Function Development Kit for Java (オープンソース) が提供されていて、これが入出力、データバインディング、Junitテスト機能などのAPIとランタイムを提供し、またビルド時に Docker イメージの作成をサポートします。
OCI Functions の実行コンテナ・イメージには何が含まれる?
fn build
コマンドで作成されるコンテナ・イメージには以下のモジュールが含まれています。
モジュール | 説明 |
---|---|
api-x.x.x.jar | fn ラインタイムと連携するためのプログミング・インターフェース |
runtime-x.x.x.jar | fn ラインタイム |
libfnunixsocket.so | Unix Domain Socket を扱うための JNI 共有ライブラリ |
xxx.jar | アプリケーションおよびFDKが依存するライブラリの jar ファイル群 |
Java が Unix Domain Socket に対応したのは比較的最近のことで、Java 11 等では直接扱うことができません。ですのでその部分を補うための Linux 共有ライブラリが FDK の一部として提供されています。
GraalVM ネイティブ・イメージを OCI Functions で動かすためには?
Dockerfile をカスタマイズする
OCI Functions の実行コンテナ・イメージの中身は分かりました。シンプルに FDK を含めた Java アプリケーション全体をネイティブ・イメージすればいいので、カスタム Dockerfile を使って fn build
すれば大丈夫そうです。
GraalVM は Maven/Gradle からネイティブ・イメージを作成するための native-maven-plugin を提供していますので、これを設定して Maven であれば docker build のステージで mvn package
すればネイティブ・イメージにできます。 libfnunixsocket.so は外から持ってくるしかないので、docker build のステージで FDK ランタイム・イメージからコピーすることにしましょう。
ということで、Dockerfile はこんな感じになります。
### STEP 1 – ネイティブ・イメージ・ビルダの入ったパブリック・コンテナ・イメージを使ってソースコードをビルドして、アプリケーションのネイティブ・イメージを作成する
FROM ghcr.io/graalvm/native-image:ol8-java17-22.1.0 as native-image-build
WORKDIR /function
ADD pom.xml pom.xml
ADD mvnw mvnw
ADD .mvn .mvn
RUN ["./mvnw", "package", "-DskipTests=true"]
ADD src src
RUN ["./mvnw", "-Pnative", "package", "-DskipTests=true"]
# ネイティブ・イメージ (func) は /function/target 下に作成される
### STEP 2 - fdk ランタイム・イメージから libfnunixsocket.so をコピーするための準備
FROM fnproject/fn-java-fdk:jre11-latest as fdk-runtime # libfnunixsocket.so is located under /function/runtime/lib
### STEP 3 – fn 仕様のコンテナ・イメージを作成する func + libfnunixsocket.so
FROM container-registry.oracle.com/os/oraclelinux:8-slim
WORKDIR /function
COPY --from=native-image-build /function/target/func . # copy native image
COPY --from=fdk-runtime /function/runtime/lib/libfnunixsocket.so . # copy libfnunixsocket.so
ENTRYPOINT ["./func", "-XX:MaximumHeapSizePercent=80", "-Djava.library.path=/function"]
CMD [ "com.example.fn.HelloFunction::handleRequest" ]
あとは、うまくネイティブ・イメージが作れるかですが、実は GraalVM の AOT コンパイラがどんな Java アプリケーションでも完全なネイティブ・イメージにコンパイルしてくれる訳ではありません。以下のような制限事項があります。
- イメージのビルド時に構成情報が必要なもの
- Dynamic Class Loading
- Reflection
- Dynamic Proxy
- JNI (Java Native Interface)
- Serialization
- 動作が異なる可能性のある機能
- Signal Handlers
- Class Initializers
- Finalizers
- Threads
- Unsafe Memory Access
- Debugging and Monitoring
- サポートされないもの
- Invoke Dynamic Bytecode and Method Handles
- Security Manager
例えばアプリケーションで Class.forName("クラス名")
のようなコードを書いていて、引数となるクラス名が実行時に設定ファイルやパラメータなどでダイナミックに渡されるようなケースでは、GraalVM の AOT コンパイラはこのクラスをコンパイル時に特定できないので、ネイティブ・イメージは生成されても実行時にエラーとなってしまいます。そのため、ここに渡されるクラス名としてどんなものがあるかを構成情報として AOT コンパイラに渡してあげないといけません。
トレーシング・エージェントを使う
構成情報は json 形式のファイルでマニュアルで編集することも可能ですが、必要な構成情報を机上で全て洗い出すのはなかなか大変作業ですし、漏れが出てくる可能性もあります。幸い、GraalVM は構成情報を自動生成してくれる Java agent 仕様の トレーシング・エージェント を提供しています。ネイティブ・イメージにコンパイルする前の普通の Java アプリケーションの状態で、起動時にこのエージェントを仕掛けておけば、指定したディレクトリに構成情報を書き出してくれます。エージェントは実行時の状況を監視しながら必要な構成情報を書き出しますので、エージェントを起動した状態でプログラム全体が走査されるようにアプリケーションを走らせます。
さて、でも Functions 用のアプリケーションって、どこで実行できますか? OCI Functions のアプリケーションとしてクラウド上で実行したとしても、吐き出された構成情報を取り出す方法 or タイミングがありません。また、ローカルの環境で OCI Functions コンテナを呼び出すためのシミュレータを作るのはかなりタフです(作れなくないと思いますが、単純に Unix Domain Socket 使って HTTP call すればいいだけの話ではないので...)。
Java FDK のテスト・ハーネスを利用する
1つの方法は、テスト・ハーネスを使ってアプリケーションをテストするタイミング (mvn test) でトレース・エージェントを起動することです。FDK にはローカル環境で 実行環境をシミュレートしてテストを実行できるテスト・ハーネスがありますので (ドキュメントはこちら)、これを活用します。 maven-surefire-plugin のオプションでトレース・エージェントを仕掛けます。
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.1</version>
<configuration>
<useSystemClassLoader>false</useSystemClassLoader>
<argLine>--add-opens java.xml/jdk.xml.internal=ALL-UNNAMED -agentlib:native-image-agent=config-merge-dir=${project.basedir}/target/native/agent-output/test/${project.groupId}/${project.artifactId},access-filter-file=${project.basedir}/access-filter.json</argLine>
</configuration>
</plugin>
<argLine>
のところが肝です。
-agentlib:native-image-agent=<options...>
の部分でエージェントを設定しています。以下の様なオプションが使えます。
- config-output-dir → 構成情報を出力するディレクトリを指定します。
- config-merge-dir → 構成情報を累積的に集めます、つまり構成情報は上書きされずに追記されます。
- access-filter-file → 今回は test フェーズで起動していますので、テスト関連のモジュールに対する構成情報も収集してしまいます。これらをフィルタするための設定を追加しています。
実践例
Eclipselink (JPA) を使って Oracle データベースにアクセスする Java アプリケーションを
ネイティブ・イメージにコンパイルして OCI Functions で動してみます。
ソース・コードはこちらにあります。
では、ビルド作業をやってみます。上記ページの「事前準備」に書いてある通り、GraalVM とネイティブ・イメージ・モジュールのインストール、Fn CLIのダウンロードとクライアント環境の構成を済ませて下さい。
$ git clone git@github.com:oracle-japan/ochacafe-graalvm.git
$ ls -l
total 16
drwxrwxr-x. 3 opc opc 65 Dec 14 09:31 helidon-jpa
drwxrwxr-x. 3 opc opc 67 Dec 14 09:31 java-app
drwxrwxr-x. 4 opc opc 4096 Dec 14 09:31 micronaut-fn-http
drwxrwxr-x. 3 opc opc 4096 Dec 14 09:31 node-app
drwxrwxr-x. 5 opc opc 4096 Dec 14 09:48 oci-functions-jpa
-rw-rw-r--. 1 opc opc 846 Dec 14 09:31 README.md
drwxrwxr-x. 3 opc opc 67 Dec 14 09:31 reflection
$ cd ochacafe-graalvm/oci-functions-jpa/
$ cat func.yaml
func.yaml を確認します。
schema_version: 20180708
name: graaljpafunc
version: 0.0.1
config:
JDBC_DRIVER: oracle.jdbc.driver.OracleDriver
# JDBC_PASSWORD か JDBC_PASSWORD_SECRET_ID のどちらかを設定する
# JDBC_PASSWORD が設定されている時は、OCI Secret にはアクセスしない
# JDBC_PASSWORD: xxxx
JDBC_PASSWORD_SECRET_ID: ocid1.vaultsecret.oc1.iad.xxxxxxxxxxxxxxxxxxxxx
# ADBに接続する場合 jdbc:oracle:thin:@<TNS alias>?TNS_ADMIN=<ADB_WALLET_DIR と同じ値>
JDBC_URL: jdbc:oracle:thin:@...
JDBC_USER: DEMO
OCI_REGION: us-ashburn-1
# ADB_WALLET_DIR が指定された場合 Wallet ファイルをダウンロードする - ADB_ID は必須
ADB_WALLET_DIR: /tmp/wallet
ADB_ID: ocid1.autonomousdatabase.oc1.iad.xxxxxxxxxxxx
func.yaml の config パートを適切に埋めて下さい。パスワードは OCI Secret に格納されている仕様なので、Secret の OCID が必要です。また、ローカル環境でテストするために、~/.oci/config が適切に設定されていることが必要です。プログラムは実行環境に応じてリソース・プリンシパルを使用するかユーザー資格証明を使用するかを自動的に切り替えます。
次にローカル環境で test を実行します。実際に OCI Secret や Oracle Database に接続してテストプログラムが実行され、構成情報が生成されます。
$ mvn test
トレース・エージェントが target/native ディレクトリ配下にファイルを書き出します。いろんな条件でテストを実行してなるべく多くのトレースが取れるようにします。全てのプログラム・コードの実行がカバーされるようにテストケースを書いて、さらにそのためのテスト条件を作れればもちろんベストです。
このファイル群をsrc/main/resources/META-INF/native-image ファイルにコピーし、さらにトレース・エージェントで漏れた追加の構成情報を編集して配置します。
今回 git clone したソースは既にネイティブ・イメージを生成するための構成情報を最初から配置していますので、この作業は省略して構いません(構成情報が完全かどうかは分かりません...)。
では OCI Functions のコンテナをビルド/デプロイします。
$ mvn wrapper:wrapper # Dockerfile 内のビルド作業で必要
$ fn deploy -v --app <OCI Functions アプリケーション名>
ネイティブ・イメージのコンパイルには結構時間がかかりますので、じっと待っていて下さい。コンソールにはネイティブ・イメージ生成の進行状況が出力されているはずです。
OCI Functions のアプリケーションとして動かす場合、このアプリケーションはリソース・プリンシパルを使用するように実装されていますので、OCI のポリシーを適切に設定して試して下さい。
Yet another GraalVM native Functions -> Micronaut
OCI Functions でネイティブ・イメージを動かすためにここまで散々頑張ってきましたが、実はそんなに頑張らなくてもフレームワーク側でちゃんと OCI Functions のネイティブ・イメージ作成をサポートしてくれるソリューションがあります。
Micronaut を使うと ネイティブ・イメージの生成のみならず OCI 関連の操作も含めてかなりの部分をフレームワークで吸収してくれてコーディング量が減らせます。
作成方法のガイドはこちらにあります。
出来上がった Docker イメージの中身を確認しましたが、Micronaut でも私が解説した方法と同様に FDK をラップした形でネイティブ・イメージ化を行っていました。
上記で紹介したサンプル・コードのリポジトリにも Micronaut 版デモのディレクトリ (micronaut-fn-http) があり、Micronaut のガイドをカスタマイズした形で OCI Secrets や Oracle Databse に接続する OCI Functions + API Gateway のサンプルになっていますので、併せてご確認いただければ幸いです。
まとめ
コツさえつかめば GraalVM のネイティブ・イメージで OCI Functions を実装するのもそれほど難しくないと感じていただけたのではないでしょうか。また Micronaut のようなフレームワークを活用するオプションもご紹介しました。ネイティブ・イメージ化によるアプリケーションの高速起動とメモリ・フットプリントの削減が皆さんのプロジェクトに恩恵を与えることができますよう願っています。
本題から逸れますが、今回案内したサンプル・コードでは、Autonomous Databse から Wallet ファイルをダウンロードしたりリソース・プリンシパルとユーザー資格証明を自動的に切換えたりしていますので、そのあたりの実装例も参考にしていただければと思います。