Quarkus Chicory を活用した Java による Go CEL ポリシーエンジンの構築
数日前、我々は Quarkus Chicory の最初のバージョン をリリースしました。これは、 Chicory WebAssembly ランタイム のパワーを Quarkus アプリケーションにもたらすエクステンションです。
開発を繰り返す中で、実際のユースケースに基づいた統合テストを実装する必要性を感じました。
一般的なシナリオについての調査と実験を重ねた結果、最終的に非常に興味深いものにたどり着きました。
Kubernetes スタイルの CEL ポリシーエンジン
CEL を使用すると、Kubernetes リソースの式ベースのポリシー検証が可能になります。この要件はオペレーターによって実装されますが、それらは 通常 Go で記述されています :-)
しかし、Keycloak オペレーターのように Java ベースの Quarkus オペレーターも存在します。ではどうすればよいでしょうか。Java で CEL ポリシー検証エンジンを一から実装しなければならないのでしょうか。それとも、 https://github.com/projectnessie/cel-java などの適切な Java ライブラリーを探すべきでしょうか。
そのどちらでもありません。ここで、Java 用の WebAssembly ランタイムである Chicory が役に立ちます。
広く使用され、十分にテストされた Go ライブラリーを再利用しましょう。Java からエクスポートされた関数をサンドボックス化された安全な方法で呼び出すだけで、すべてが解決します。
なぜか?
-
書き直しの必要がない
-
メンテナンスコストが低い
-
1:1 の動作再現
Quarkus アプリケーション用 Chicory エクステンション
Chicory は、オープンソースの 100% ネイティブ Java WebAssembly (Wasm) ランタイムです。
その主な目的は、Java 開発者がネイティブライブラリー、JNI (Java Native Interface) の使用、または unsafe なコードに依存することなく、JVM 内で Wasm モジュールを実行できるようにすることです。
プラットフォーム固有のバイナリーを必要とする他の Wasm ランタイムとは異なり、Chicory はピュア Java です。
JVM のメモリー空間内で Wasm コードを実行し、「ダブルサンドボックス」効果 (Wasm の分離 + JVM のセキュリティー) を提供します。また、Wasm モジュールがシステムリソースと安全にやり取りできるようにする WASI (WebAssembly System Interface) サポートも提供します。さらに、高速な実行のための インタープリター と ランタイムコンパイラー の両方に加え、Wasm を Java バイトコードに変換して最適なパフォーマンスを実現する ビルド時コンパイラー も含まれています。詳細は Chicory execution modes を参照してください。
これはプラグインシステムに理想的であり、ユーザーが Wasm にコンパイルされる任意の言語 (Rust、C++、Go など) で安全なプラグインを記述し、それを Java アプリ内で実行できるようにします。また、Java ベースのインフラストラクチャー上でアプリケーションコンテナーのオーバーヘッドなしに軽量なロジックを実行する手段を提供するため、サーバーレスやエッジコンピューティングのケースにも使用できます。
そして、古びることのないクロスプラットフォームのポータビリティーについても、アーキテクチャーごとに異なる .so や .dll ファイルを管理することなく、アプリケーションは "Write Once, Run Anywhere" 状態を維持できます。
Chicory についての詳細を確認できますが、ここからはその Quarkus エクステンションに焦点を当てましょう。
Quarkus Chicory は、Chicory を Quarkus アプリケーション開発者にもたらし、その機能を Quarkus エコシステムやアプリケーションのビルド時および実行時の特性に統合して、自然な開発体験を提供します。まさに、ただ動くのです!
主な機能
-
ビルド時のコード生成
WebAssembly モジュールから Java バイトコードを生成し、Chicory Maven プラグインを置き換えます。
-
依存関係管理
Quarkus と Chicory の ASM 依存関係間のバージョン整合性を自動的に処理します。
-
マルチ WASM モジュールサポート
複数の WebAssembly モジュールを構成および管理します。
-
動的ロード
実行時にロードされる WASM モジュールを管理します。
-
実行モードのインテリジェントな選択
環境に基づいて MachineFactory と WasmModule インスタンスを構成します。 MachineFactory は、本番環境やネイティブ環境で最適なパフォーマンスを得るためにビルド時に生成されたバイトコードを活用し、開発やテストの際にはランタイムモードやインタープリターモードを使用できます。同様に、 WasmModule インスタンスも、ビルド時のコード生成プロセスを活用することで、純粋な WASM ペイロードではなく WASM メタデータを使用して初期化でき、パフォーマンスとメモリーフットプリントを向上させることができます。
-
開発時のライブリロード
静的モジュールは自動的に監視され、リロードされます。誰もがお気に入りの quarkus:dev を、Rust や Go のモジュールでも利用できると考えてください。
-
Native Image 互換性
-
WASM/WASI サポート
このポテンシャルを手に、我々はいくつかの Go オペレーターで採用されている人気の高い Google CEL (Go ライブラリー/API) を選択し、アプリケーションに統合することにしました。
アプリケーションのセットアップと設定
アプリケーションの作成は、Quarkus Maven プラグインを使用して簡単に行えます。Quarkus 3.x には Java 17 以上が必要であることに注意してください (この例では Java 21 を使用しました)。
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=io.quarkiverse.chicory.demo \
-DprojectArtifactId=quarkus-cel-k8s-validator \
-Dextensions='rest'
次に、Quarkus Chicory エクステンションを pom.xml に追加します。
<properties>
<dylibso.version>1.6.1</dylibso.version>
<quarkus-chicory.version>0.0.1</quarkus-chicory.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>${quarkus.platform.artifact-id}</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.dylibso.chicory</groupId>
<artifactId>wasi</artifactId>
<version>${dylibso.version}</version>
</dependency>
<dependency>
<groupId>io.quarkiverse.chicory</groupId>
<artifactId>quarkus-chicory</artifactId>
<version>${quarkus-chicory.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkiverse.chicory</groupId>
<artifactId>quarkus-chicory</artifactId>
</dependency>
<dependency>
<groupId>com.dylibso.chicory</groupId>
<artifactId>wasi</artifactId>
</dependency>
<!-- more dependencies here... -->
</dependencies>
最後に、WASM モジュールの設定を application.properties に記述します。
quarkus.chicory.modules.go-cel.name=io.quarkiverse.chicory.demo.GoCelModule
quarkus.chicory.modules.go-cel.wasm-file=src/main/resources/wasm/go-cel.wasm
これにより、Quarkus Chicory に対して、指定された WASM ファイルからビルド時に GoCelModule クラスを生成するように指示します。エクステンションは、WebAssembly モジュールから Java バイトコードを自動的に生成し、実行環境に基づいて適切な MachineFactory を構成し、開発モードでは WASM ファイルの変更を監視してライブリロードをトリガーします。
最後のステップです。 WasmModuleInterface アノテーション を使用し、 アノテーションプロセッサー を設定します。これにより、Chicory は Go のエクスポートされた関数と 1:1 でマッピングされるメソッドを含む Java クラスを生成します。
まず、アノテーションを配置する K8sCel Java クラスを作成しましょう。
package io.quarkiverse.chicory.demo;
import com.dylibso.chicory.annotations.WasmModuleInterface;
@WasmModuleInterface(WasmResource.absoluteFile)
public class K8sCel {
private K8sCel() {}
}
ビルド時に、Chicory アノテーションプロセッサーはこのアノテーションを検出し、エクスポートされたメソッドを提供する K8sCel_ModuleExports クラスを生成します。
package io.quarkiverse.chicory.demo;
import com.dylibso.chicory.runtime.ExportFunction;
import com.dylibso.chicory.runtime.Instance;
import com.dylibso.chicory.runtime.Memory;
public class K8sCel_ModuleExports {
private final ExportFunction field__start;
private final ExportFunction field_malloc;
private final ExportFunction field_free;
private final ExportFunction field_evalPolicy;
private final Memory field_memory;
public K8sCel_ModuleExports(Instance instance) {
this.field__start = instance.exports().function("_start");
this.field_malloc = instance.exports().function("malloc");
this.field_free = instance.exports().function("free");
this.field_evalPolicy = instance.exports().function("evalPolicy");
this.field_memory = instance.exports().memory("memory");
}
public void _start() {
this.field__start.apply(new long[0]);
}
public int malloc(int arg0) {
long result = this.field_malloc.apply(new long[]{(long)arg0})[0];
return (int)result;
}
public void free(int arg0) {
this.field_free.apply(new long[]{(long)arg0});
}
public int evalPolicy(int arg0, int arg1, int arg2, int arg3) {
long result = this.field_evalPolicy.apply(new long[]{(long)arg0, (long)arg1, (long)arg2, (long)arg3})[0];
return (int)result;
}
public Memory memory() {
return this.field_memory;
}
}
これだけで十分です!
WasmQuarkusContext API
このサンプルアプリケーションは、関心事が分離されたクリーンアーキテクチャパターンに従っています。
-
K8sCelValidatorService - WASM 統合とビジネスロジックを管理
-
K8sCelValidatorResource - REST API エンドポイントを提供
Quarkus Chicory エクステンションは、設定された WASM モジュールにアクセスするためのインジェクション用 WasmQuarkusContext API を提供します。サービスでの使用方法は次のとおりです。
@ApplicationScoped
public class K8sCelValidatorService {
@Inject
@Named("go-cel")
WasmQuarkusContext wasmQuarkusContext;
Instance instance;
K8sCel_ModuleExports exports;
@PostConstruct
public void init() throws IOException {
WasmModule wasmModule = wasmQuarkusContext.getWasmModule();
if (wasmModule == null) {
throw new IllegalStateException("Wasm module " + wasmQuarkusContext.getName() + " not found!");
}
// Create WASI support for stdout/stderr
WasiOptions options = WasiOptions.builder()
.withStdout(new ByteArrayOutputStream())
.withStderr(new ByteArrayOutputStream())
.build();
WasiPreview1 wasi = WasiPreview1.builder()
.withOptions(options)
.build();
Store store = new Store().addFunction(wasi.toHostFunctions());
instance = Instance.builder(wasmModule) (1)
.withMachineFactory(wasmQuarkusContext.getMachineFactory())
.withImportValues(store.toImportValues())
// Don't auto-run _start(), we'll call it manually
.withStart(false)
.build();
// Get exported functions BEFORE calling _start
exports = new K8sCel_ModuleExports(instance); (2)
// Initialize Go runtime by calling _start()
// This is required to perform initialization, i.e. to run main(), which indeed should exit with 0,
// so we catch the expected WasiExitException accordingly.
try {
exports.start(); (3)
} catch (com.dylibso.chicory.wasi.WasiExitException e) {
// Expected - Go main() exits after completing
if (e.exitCode() != 0) {
throw new RuntimeException("Go runtime initialization failed with exit code: " + e.exitCode());
}
// Exit code 0 is success - runtime is now initialized and exported functions are ready
}
}
public ValidationResult validate(final String resourceJson, final String celPolicy) {
byte[] policyBytes = celPolicy.getBytes(StandardCharsets.UTF_8);
byte[] inputBytes = resourceJson.getBytes(StandardCharsets.UTF_8);
// Allocate memory for policy string in WASM
int policyPtr = exports.malloc(policyBytes.length);
if (policyPtr == 0) {
throw new IllegalStateException("Failed to allocate memory for policy");
}
// Allocate memory for input JSON in WASM
int inputPtr = exports.malloc(inputBytes.length);
if (inputPtr == 0) {
throw new IllegalStateException("Failed to allocate memory for input");
}
try {
// Write policy and input to WASM memory
exports.memory().write(policyPtr, policyBytes);
exports.memory().write(inputPtr, inputBytes);
// Call evalPolicy(policyPtr, policyLen, inputPtr, inputLen)
int returnCode = exports.evalPolicy(policyPtr, policyBytes.length, inputPtr, inputBytes.length);
// Interpret result
if (returnCode == 11) {
return new ValidationResult(VALIDATION_RESULT_ALLOWED, "Policy ALLOWS the request", celPolicy);
} else if (returnCode == 0) {
return new ValidationResult(VALIDATION_RESULT_DENIED, "Policy DENIES the request", celPolicy);
} else {
// Negative values are errors
String errorMsg = switch (returnCode) {
case -1 -> "JSON parse error";
case -2 -> "CEL environment creation error";
case -3 -> "CEL compilation error";
case -4 -> "CEL program creation error";
case -5 -> "CEL runtime error";
default -> "Unknown error: " + returnCode;
};
return new ValidationResult(VALIDATION_RESULT_ERROR, "CEL evaluation failed: " + errorMsg, celPolicy);
}
} finally {
// Free allocated memory in WASM
exports.free(policyPtr);
exports.free(inputPtr);
}
}
public static final String VALIDATION_RESULT_ALLOWED = "allowed";
public static final String VALIDATION_RESULT_DENIED = "denied";
public static final String VALIDATION_RESULT_ERROR = "error";
public record ValidationResult(String status, String message, String policy) {}
}
| 1 | インジェクトされた WasmQuarkusContext Bean は、アプリケーションの設定と実行環境に基づいて動的に作成される MachineFactory および WasmModule インスタンスを使用するように Instance.Builder を構成します。 |
| 2 | instance が構築されると、 export は K8sCel_ModuleExports インスタンスで初期化され、後に validate() メソッドで呼び出されるエクスポート関数を提供します。 |
| 3 | Go ランタイムを初期化するために、エクスポートされた "_start" 関数が呼び出されます。これにより、Go プログラムの main() 関数が実行されます。今回の実装では中身が空であるため、すぐに終了します。そのため、 WasiExitException をキャッチして、終了コードが 0 (エラーなし) であることを確認します。 |
マルチユーザーとスレッドセーフに関する注意
WasmQuarkusContext インスタンスは @ApplicationScoped Bean としてインジェクトされます。これは、単一のアプリケーションインスタンスが複数のクライアント (またはユーザーリクエスト) やスレッドによって使用される可能性があることを意味します。とはいえ、API の実装は ステートレス であり、 getMachineFactory() と getWasmModule() は常に新しいインスタンスを返します。
これらのインスタンスの扱い方やライフサイクルのオーケストレーション方法は、アプリケーションドメインに属する事項です。例えば、上記のターゲット実装は並行性を考慮していません。複数のスレッドが同じ Memory インスタンスを消費する場合、共有されている WasmModule の線形メモリーの破損を避けるために、スレッドセーフな実装が必要になります。
アプリケーションコードに戻ると、REST リソースは単純な委譲レイヤーとなります。
@Path("/k8s")
public class K8sCelValidatorResource {
@Inject
K8sCelValidatorService validatorService;
@POST
@Path("/validate")
public Response validate(@RestForm String resourceJson, @RestForm String celPolicy) {
ValidationResult result = validatorService.validate(resourceJson, celPolicy);
Response.Status status = switch (result.status()) {
case "allowed" -> Response.Status.OK;
case "denied" -> Response.Status.FORBIDDEN;
default -> Response.Status.BAD_REQUEST;
};
return Response.status(status).entity(result).build();
}
}
WasmQuarkusContext API は、2 つの主要なメソッドを提供します。
-
getWasmModule(): 解析された WebAssembly モジュールを返します。 -
getMachineFactory(): 環境に基づいた適切なMachineFactoryを返します (開発時はインタープリター、本番/ネイティブ時はビルド時コンパイル)。
@Named 修飾子は、application.properties のモジュール名と一致します。エクステンションが WASM モジュールのライフサイクルの複雑さをすべて処理するため、開発者はビジネスロジックに集中できます。
仕組み: Go → Wasm → Java バイトコード
アプリケーションの核心は、WebAssembly にコンパイルされた Go CEL 実装です。Go コードは、3 つの主要なエクスポート関数を実装しています。
//go:wasmexport evalPolicy
func evalPolicy(policyPtr, policyLen, inputPtr, inputLen uint32) int32 {
// Convert pointers to Go types
policy := unsafe.String((*byte)(unsafe.Pointer(uintptr(policyPtr))), policyLen)
inputJSON := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(inputPtr))), inputLen)
// Parse the JSON input
var input map[string]any
if err := json.Unmarshal(inputJSON, &input); err != nil {
return -1 // JSON parse error
}
// Create CEL environment
env, err := cel.NewEnv(
cel.Declarations(
decls.NewVar("object", decls.NewMapType(decls.String, decls.Dyn)),
),
)
if err != nil {
return -2 // CEL environment creation error
}
// Compile and evaluate the CEL expression
ast, iss := env.Compile(policy)
if iss.Err() != nil {
return -3 // Compilation error
}
prg, err := env.Program(ast)
if err != nil {
return -4 // Program creation error
}
out, _, err := prg.Eval(map[string]any{"object": input})
if err != nil {
return -5 // CEL runtime error
}
// Return 1 for allow, 0 for deny
if b, ok := out.Value().(bool); ok && b {
return 1
}
return 0
}
この Go コードは、WASI ターゲットを使用して WASM にコンパイルされます。
GOOS=wasip1 GOARCH=wasm go build -o go-cel.wasm main.go
サービスの実装において:
-
Java はポリシー文字列と入力リソースマニフェスト (JSON) 用に WASM メモリーを割り当て、データを WASM メモリーに書き込みます。
-
Java は、引数へのポインターとそのサイズを指定して
evalPolicy()を呼び出します。 -
Go コードはそのメモリー空間から読み取り、Google CEL ライブラリーを使用して CEL 式を評価します。
-
Go は整数の結果コードを返します (1=許可、0=拒否、負の値=エラー)。
-
Java は結果を解釈し、クリーンアップを実行します。
これは WebAssembly のパワーを示しています。成熟し、十分にテストされた Google CEL-Go ライブラリーを、CEL を一から再実装することなく Java から使用できるのです。
WASM の境界は、2 つの言語間にクリーンで安全なインターフェースを提供します。
本番環境では、Go オペレーターと同じように Kubernetes リソースを検証する CEL ポリシーを記述できます。
// Require production label
has(object.metadata.labels.env) && object.metadata.labels.env == "production"
// Deny privileged containers
!(has(object.spec.containers) && object.spec.containers.exists(c,
has(c.securityContext) && c.securityContext.privileged == true))
// Require resource limits
has(object.spec.containers) && object.spec.containers.all(c,
has(c.resources) && has(c.resources.limits))
結論
Quarkus Chicory と、WebAssembly にコンパイルされた Google CEL-Go を組み合わせることで、完全に Java で動作する Kubernetes スタイルの CEL ポリシーエンジンを作成しました。
このアプローチにはいくつかの利点があります。
-
既存の Go ライブラリーの再利用: Java で CEL を再実装する必要がなく、オリジナルの Go コードと 1:1 で対応します。
-
型安全性とパフォーマンス: Quarkus Chicory が WASM モジュールから Java バイトコードを生成します。
-
本番環境への対応: Go オペレーターで使用されているものと同じ CEL ライブラリーが、Java でも利用可能になります。
-
開発者体験: ライブリロード、ビルド時のコード生成、および Native Image サポート。
-
エコシステムの互換性: Java ベースの Kubernetes オペレーター とシームレスに連携します。
これは、WebAssembly が単なるブラウザー技術ではなく、クラウドネイティブアプリケーションにおける言語間相互運用のための強力なツールであることを示しています。また、Quarkus Chicory のおかげで、このワークフローを Quarkus アプリケーションに簡単に統合できることも実証されました。
Kubernetes オペレーターを構築する Java 開発者にとって、このアプローチは JVM を離れることなく Go エコシステムの大部分を活用する道を切り開きます。
完全な動作例は、こちらで確認できます。 https://github.com/fabiobrz/quarkus-cel-k8s-validator