※本記事は、Anton Epple による “Cloud-agnostic serverless Java with the Fn project and GraalVM” を翻訳したものです。

本記事を PDF でダウンロード(英語)


言語の選択はアプリケーションの移植性に重大な影響を与えるものであり、サーバーレス・アプリケーションを構築する新興企業にとって非常に重要となり得る

September 25, 2020
 

クラウド・プラットフォーム、とりわけFunction-as-a-Service(FaaS)サービスは、新興企業にとって大きな味方です。使い始めるのが容易で、使った分だけ支払えばよく、高可用性と低メンテナンス性が保証されています。また、アプリケーションは需要の伸びに応じて自動的にスケーリングされます。

アーキテクチャに注目するときは、移植性について考えてください。移植性のあるクラウド・アプリケーションの標準を設けようという取り組みはありますが、ほとんどのプラットフォームで提供されているのは、独自の専用ツール群が組み込まれた統合システムです。これによって、使用を始めるのは簡単になる反面、使用をやめるのは難しくなります。

本記事は2回シリーズの1回目で、マルチクラウドでのアプリケーションの移植性を保つために役立つツールやベスト・プラクティス、テクノロジーに注目します。今回は、Fn ProjectGraalVMネイティブ・イメージによるJavaネイティブ・サーバーレス・ファンクションの利点を中心に説明します。

本記事で紹介するツールやテクニックのデモとなるサンプル・プロジェクトを作成しています。記事の中で説明していますが、このコードをOracle Cloud Infrastructureにデプロイしたい場合のために、チュートリアルとヘルパー・スクリプトも作成しました。

 

クラウドを使ってみる

2018年のことでした。オフグリッド環境でも動作する電子鍵のアクセス権管理を行う新興企業のアイデアを持つ古い友人から、意見を求められました。ネットワーク接続機能のない、顧客の鍵やセンサーに対し、モバイル・アプリによってバックエンドへの接続を提供するという仕組みです。私たちは多少のプロトタイピングを行い、そう時間をかけることなく、このアイデアは実現可能であると判断しました。筆者もチームに参加することを決め、2018年11月に共同でSmart Access Solutions(SAS)を設立しました。

このような新興企業にとってIaaSは、データセンターの構築について心配せずに、すべての労力をプロダクト開発に振り向けることができるという点で重宝します。調査の結果、スケーラビリティとコスト管理のバランスが最適なIaaSオプションはFaaSモデルでした。FaaSモデルはサーバーレス・アーキテクチャとも呼ばれます。中核プロダクトはクラウドベースのシステムにすることを計画していたので、図1に示すようなサーバーレス方式を採用することにしました。

図1:Smart Access Solutions のセキュア・サーバーレス・クラウド

 

コードの移植性を維持する

サーバーレスの世界は、サーバーを中心とした従来型のJavaの世界とはまったく異なります。広く採用されている基準がないこともあり、具体的なツールやテクノロジーを選ぶことが難しくなっていて、特定のプラットフォームへのロックインが起こりやすい状況です。さらに、専用のフレームワークやツール、サービスと密接に統合されたプラットフォームを使えば、開発の初期コストは驚くほど低下します。しかし、あるプラットフォームに特化したプラットフォームを選べば、後から別のプロバイダに移動するのは難しくなるかもしれません。

新興企業や多くの既成組織では、可能な限りプラットフォーム独立を維持するというのが優れた戦略です。つまり、クラウド・プロバイダが、あるサービスの提供を終了した場合などに備えて、移植性を考慮した設計をするということです。さらに優れたテクノロジーや魅力的な価格モデルを提供する別のプロバイダへの移動が、いつでも自由で、しかも比較的容易であるべきです。

アプリケーションの移植性には多くの要素が影響しますが、2つのクラウド・プロバイダを見て、それぞれのプラットフォームでアプリケーションを実行するために必要な設計を考えるという原則に従うとよいでしょう。

データやファイルの保存、ネットワーク、メッセージング、認証と認可、監視などのサービスは、プロバイダによって異なります。実際に設計を考えてみるという過程は、使用中のプラットフォーム固有サービスを特定するうえで役立ちます。そうすれば、プラットフォーム固有のサービスを、両方のプラットフォームで利用できる別のサービスで置き換えることにより、負の影響を最小限にとどめることができます。

移植性は、ビジネス・コードを独自仕様のAPIから守るための抽象化にも役立ちます。こういった抽象化を行ったり、APIコントラクトを強制したりすることがどのくらい簡単であるかは、プログラミング言語次第かもしれません。ほとんどのプラットフォームでは多くの言語ランタイムを提供しているので、どのプログラミング言語を使うかはそれほど重要ではないように思えます。しかし実際のところ、言語の選択は移植性に重大な影響を与えます。

 

プログラミング言語を意識する

例として、JavaScriptランタイムのNode.jsについて考えてみます。当社でも、Node.jsはいくつかの小規模な顧客プロジェクトで大変良好に動作しました。通常、サーバーレス・ファンクションは、リクエストごとにコンテナ化されたランタイムが起動してファンクションが実行されるような実行環境で動作します。JavaScriptには、Javaに比べてコールド・スタートの待機時間が短く、メモリのフットプリントが小さいという特徴があります。

その結果、サーバーレス・ファンクションがたまにしか起動しない場合でも、JavaScriptはうまく動作します。サーバーレス・ファンクションは実行時間とメモリ使用量によって課金されるため、JavaScriptを使う方がJavaを使うよりも安価になる可能性があります。さらなるメリットとして、JavaScriptの開発は、コンパイルの手順がなく、非常に高速であることが挙げられます。ファンクションは小さな独立したコード・ユニットなので、厳密なAPIコントラクトを定義して強制するという考え方が言語になくても、さほど問題とはならないはずです。そのため、Node.jsは優れた選択肢であるように思えました。しかし、私たちが選択したものは違います。

熟慮の結果、私たちは中核プロダクトのSAS Secure Cloud Core(アクセス権、センサー・データ、予想分析の管理を行うマルチテナント・システム)にJavaを使うことにしました。

その理由は、アプリケーションの移植性を可能な限り維持できるようにする必要があるためです。私たちの狙いは、希望したときにクラウド・プロバイダを切り替えられること、そしてアプリケーションをマルチクラウド環境でもオンプレミス環境でも実行できるようにすることにあります。

別のデータ・ストレージ、別のメッセージング・キュー、別のサービスにすばやく切り替えることができるように、ベンダー固有の部分はすべてビジネス・ロジックから分離し、抽象化の背後に隠蔽しておく必要があります。そこで登場するのがJavaです。

Javaには、パッケージ、メソッド、フィールドの可視性を細かく制御する仕組みがあります。インタフェースや抽象クラスを使うことで、APIやサービス・プロバイダ・インタフェース(SPI)の定義が容易になります。コンパイラでは、APIコントラクトが自然な形で強制され、意図しない使用を防ぎます。この特徴は、大規模なチームが協調して作業する際にぴったりです。

このような抽象化の典型的な例として挙げられるのが、データの永続化です。Javaでは、インタフェースを作るだけで、キーバリュー・ストア、リレーショナル・データベース、グラフ・データベースなどのどこにデータが格納されているかにかかわらず、透過性を保つことができます。次に示すのは、私たちが使用しているものを簡略化した例です。


public abstract class Repository <T>{
    private final Class t;
    protected Repository (Class<T> t){
      this.t = t;
    };
    public abstract Optional<T> create(T entity);
    public abstract Optional<T> get(String id);
    public abstract List<T> getAll();
    public abstract boolean update(T entity);
    public abstract boolean delete(String id);
    
    public final boolean canHandle(Class v) {
        return t.isAssignableFrom(v);
    }
}

ビジネス・コードには、ServiceLoader を提供できます。たとえば、次のようにします。


public class RepositoryServiceLoader {

    public static <T> Repository<T> getRepository(Class<?> t) {

        ServiceLoader< Repository> loader = ServiceLoader.load(Repository.class);
        for (Repository repository : loader) {
            if (repository.canHandle(t)) {
                return repository;
            }
        }
        throw new UnsupportedOperationException( "No Repository for "+t);
    }
}

これで、実際のサービス実装では、クラウド・プロバイダがサポートするものであれば、どんなリレーショナル・データベースやキーバリュー・ストアでも使用できるようになります。次に示すのは、テスト用に使用できるHashMapに基づいたダミーの実装です。


 public class MockRepository extends Repository<MockElement>{
    private static Map<String, MockElement> users = new HashMap<>();
    public MockRepository() {
        super(MockElement.class);
    }

    @Override
    public Optional<MockElement> create(MockElement entity) {
        MockElement put = users.put(entity.id, entity);
        return Optional.of(entity);
    }

    @Override
    public Optional<MockElement> get(String id) {
        return Optional.ofNullable(users.get(id));
    }

    @Override
    public List<MockElement> getAll() {
        return new ArrayList<>(users.values());
    }

    @Override
    public boolean update(String id, MockElement entity) {
        MockElement get = users.get(id);
        return get == null ? false : users.put(id, entity) == null? false: true;
    }

    @Override
    public boolean delete(String id) {
        return users.remove(id)==null ? false: true;
    }
    
}

実装は、Javaモジュールとして登録するか、またはクラスパスを使用して登録することができます。サービス・ロケータ・パターンが適切だと思わないなら、代わりにインジェクション・フレームワークを使ってサービスを提供することもできます。どちらも、Javaで十分にサポートされている単純で容易な方法です。

JavaScriptなどの動的言語では、このような機能が不十分です。1人か2人の開発者が携わる小さなプロジェクトでライフスパンが限られていれば、JavaScriptを使用しても安心です。しかし、ビジネスの中核を形成する大規模なマルチモジュール・プロジェクトの場合、サーバーレス空間での欠点はあるものの、Javaの方がはるかによく適合すると判断しました。

APIコントラクトが厳密に強制されることも大きな利点です。というのも、開発者を導いてくれるだけでなく、望まない依存性が意図せずに紛れ込むことを防いでくれるからです。APIのユーザーが、たとえ内部動作について何も知らなくても、APIを使用できることが必要です。

この条件を満たすうえで、Javaのような強く型付けされた言語が持つ価値は計り知れません。コントラクトに従ったコードを開発者が書くときにIDEによるアシストが得られ、コーディング中にドキュメントがIDEからエディタに直接提供されるからです。加えて、大規模プロジェクトで利用できるツールも非常に充実しています。Apache Mavenプロジェクト管理システムを使えば、マルチモジュール・ビルドの設定も簡単で、ビルド・アーティファクトの共有もMavenリポジトリを使って管理することができます。すばらしい機能を持つこのツールは、ほとんどのDevOpsプラットフォーム(中には無料のものもあります)に組み込まれています。

 

サーバーレス Fn Project

Java(やその他の言語)でサーバーレス・ファンクションを記述するうえで、興味深いプラットフォームがFn Projectです。Fn Projectは、Dockerをベースにしたオープンソースのコンテナ・ネイティブなサーバーレス・プラットフォームで、Function Development Kit(FDK)を使ってビジネス・コードをDockerコンテナとしてパッケージ化します。

Fnは、特定のクラウドに依存しないことを目的としており、ランディング・ページには、「Fn Projectは、オープンソースのコンテナ・ネイティブなサーバーレス・プラットフォームで、どんなクラウドでも、オンプレミスでも、どこでも実行できます」と書かれています。そのため、移植性のツールボックスに追加するにふさわしいものだと考えました。

Fnのインストール・プロセスはシンプルです。まず、Dockerをインストールする必要があります。macOSとLinuxでは、シェルからFnサーバーとコマンドライン・インタフェース(CLI)をインストールすることができます。Windowsでは、Dockerのセットアップが難しいので、少なくともテスト用にはLinuxを実行する仮想マシンを使うとよいでしょう。


$ curl -LSs https://raw.githubusercontent.com/fnproject/cli/master/install | sh
$ fn start
 
[ // 数行を省略 ]
        ______
       / ____/___
      / /_  / __ \
     / __/ / / / /
    /_/   /_/ /_/
        v0.3.339

time="2020-05-13T12:23:16Z" level=info msg="Fn serving on `:8080`" type=full

ローカル・インストールが完了し、開発サーバーが稼働したら、FDKを使ってファンクションの開発を始めることができます。次のコマンドを実行すると、functionという名前の新しいディレクトリにMavenプロジェクトが生成されます。


$ fn init --runtime java function

src フォルダの中に、次のデモ・コードがあります。


public class HelloFunction {

    public String handleRequest(String input) {
        String name = (input == null || input.isEmpty()) ? "world"  : input;

        System.out.println("Inside Java Hello World function"); 
        return "Hello, " + name + "!";
    }

}

生成されたソース・コードは、入力を文字列として受け取り、結果を文字列として返すメソッドです。フレームワーク自体には何の依存性もないので、抽象化を追加して、漏れ出してくる依存性からビジネス・コードを守る必要はありません。コードは完全な移植性をすでに備えています。デプロイを行うには、アプリを作成する必要があります。アプリとは、所属するファンクションをグループ化する単位です。その後、Fn CLIからファンクションを呼び出します。次の例をご覧ください。


$ fn create app java-app
$ fn deploy --app java-app --local
$ fn invoke java-app function
time="2020-05-17T07:47:58Z" level=info msg="starting call" action="server.handleFnInvokeCall)-fm" app_id=01E88VPC6ANG8G00GZJ0000002 call_id=01E8GSN4RBNG8G00GZJ0000007 container_id=01E8GSN4RENG8G00GZJ0000008 fn_id=01E88VSCE1NG8G00GZJ0000003
Hello, world!

ファンクションを HTTP エンドポイントから起動することもできます。まず、アドレスを確認する必要があります。たとえば、次のようにします。


$ fn inspect function java-app function
{
        "annotations": {
                "fnproject.io/fn/invokeEndpoint": "http://localhost:8080/invoke/01E88VSCE1NG8G00GZJ0000003"
        },
        "app_id": "01E88VPC6ANG8G00GZJ0000002",
        "created_at": "2020-05-14T05:51:18.465Z",
        "id": "01E88VSCE1NG8G00GZJ0000003",
        "idle_timeout": 30,
        "image": "function:0.0.2",
        "memory": 128,
        "name": "function",
        "timeout": 30,
        "updated_at": "2020-05-14T05:51:18.465Z"
}
$ curl -X "POST" -H "Content-Type: application/json" http://localhost:8080/invoke/01E88VSCE1NG8G00GZJ0000003       
time="2020-05-18T10:55:10Z" level=info msg="starting call" action="server.handleFnInvokeCall)-fm" app_id=01E88VPC6ANG8G00GZJ0000002 call_id=01E8KPRMMPNG8G00GZJ000000F container_id=01E8KPRMMQNG8G00GZJ000000G fn_id=01E88VSCE1NG8G00GZJ0000003
Hello, world!%  

JSONによる入力を受け取るには、Plain Old Java Object(POJO)を受け取るように、メソッドのシグネチャを変更します。Fnでは、受け取ったJSONメッセージを自動的にPOJOのフィールドにマッピングし、そのフィールドをオブジェクトとしてハンドラ・メソッドに渡します。ここでも、コードに追加の依存性は必要ありません。

 

Kubernetes にデプロイする

Fnのプロジェクトは、ソース・コードのレベルで移植性を備えているだけではありません。プラットフォーム自体も、任意のクラウド環境で実行できます。

Fnを本番環境で使用するために、プロジェクトでTerraformモジュールが提供されています。このモジュールは、監視やメトリックを含む本番対応環境をさまざまなクラウドにデプロイするためのものです。

唯一の前提条件となるのが、Kubernetesクラスタです。このクラスタはオンプレミスでも実行できます。当社では、ほとんどの顧客がホステッド・ソリューションを使います。しかし、中にはインターネットに接続しない顧客も存在するため、オンプレミスでファンクションを実行できることも1つの要件でした。

Kubernetesクラスタは、大部分のクラウド環境においてセットアップが容易であり、将来的な選択肢としては興味深いかもしれませんが、私たちが求めていた真のサーバーレス・アプローチではありません。ありがたいことに、真にサーバーレスな選択肢もあります。Oracle Functionsです。
 

Oracle Functions

Oracle Functionsは、Oracle Cloud Infrastructureの一部として提供されています。

Oracle Functionsを使うと、Fnのファンクションをマネージド環境にデプロイすることができます。この作業は、Fn CLIから直接行うことができます図2参照)。自分で試してみたい方は、無償のOracle Cloud Infrastructureテスト・アカウントがあれば十分です。セットアップとデプロイについては、前述のとおり、筆者が作成したチュートリアルで説明しています。


図2:セルフ・マネージドKubernetesクラウドを使ったローカルへのFnのデプロイと、フル・マネージドOracle FunctionsへのFnのデプロイ

Fn Projectは、オンプレミスでも任意のクラウドでも実行できます。ホステッド・サーバーレス・ソリューションとしてOracle Functionsを使うこともできます。このプラットフォームではファンクションのコードに依存性が紛れ込むことがないので、別の環境でコードを再利用するのも簡単です。Fnがアプリケーションの移植性を保つうえで手堅い選択だと言えるのはそのためです。しかし、この時点で、Javaのコールド・スタートとメモリ使用量の問題はまだ解決されていません。この点について、別のプロジェクトが大変有用であることが判明します。GraalVMです。

 

GraalVM ネイティブ・イメージ

Javaでサーバーレス・ファンクションの開発を始めたとき、私たちにとってコールド・スタートの問題は非常に顕著でした。ファンクションによっては、ベンダーが設定した実行タイムアウトのデフォルト値を増やさなければならなかったからです。

ストレージのサイズとメモリ使用量も問題でした。ここでも、デフォルト値を増やしてメモリ不足の問題を回避する必要がありました。そのため、GraalVMがリリースされたときは非常に興奮しました。

GraalVMはOpenJDKをベースとしたJVMおよびJDKです。また、高パフォーマンスなJVMベースの言語や、多言語アプリケーションを改善することを目的としたツールのエコシステム全体でもあります。オーストリアのリンツにあるOracle Labsによるこの偉大なプロジェクトに、筆者はずっと注目してきました。10年近く前にSun Microsystemsの「Maxine」プロジェクトからこのプロジェクトが生まれたときからです。特に注目すべき点は、GraalVMではJavaのコールド・スタート問題に対する解決策も提供されていることです。

通常、Javaアプリケーションは優れたパフォーマンスを発揮します。その理由は、HotSpot JVMのJust-In-Time(JIT)コンパイラでランタイムの動作を分析し、その情報に基づいて、コードのもっとも重要な部分のバイトコードをネイティブ命令にコンパイルしているからです。ただし、このプロセスにはウォームアップ期間が必要です。ウォームアップは、対象のコードが初めて実行されるときに行われます。さらに、アプリケーションの起動時には、クラスのロードと検証を行うためにいくらかの時間がかかります。新しいサーバーレス・コンテナが起動してリクエストに対応するたびに、無視できない遅延や、さらにはタイムアウトが発生することもあるのはそのためです。

それでは、どうすればサーバーレス・ファンクションがウォームアップされた状態を保つ、すなわち、コールド・スタートによる遅延を避けることができるのでしょうか。もっとも一般的な解決策は、ファンクションを実行するイベントを定期的に作成することです。これは基本的に、ステートレスなサーバーレス・ファンクションをステートフルなサーバーに変えることになります。

このアプローチには欠点もあります。各コンテナは一度に1つのリクエストにしか対応しないので、リクエストが増加すると新しいコンテナが起動することになります。そのためいずれにせよ、いくつかの同時リクエストに対応するには、コンテナのプールをウォームアップする必要があります。実際のリクエストをブロックせずに処理を早期終了できるように、コードを調整してウォームアップ・イベントを識別しなければならない場合もあるでしょう。そのため、使用していないときは無料であるサーバーレス・ファンクションのようなわけにはいかず、コンテナ化したサーバーのクラスタがアイドル状態であっても課金されることになります。

うれしいことに、GraalVMがリリースされたことで、このような細工を省くことができます。GraalVMでは、ネイティブ・イメージと呼ばれるネイティブの実行可能ファイルをJavaコードから生成できます。このプロセスの中で、native-imageツールにおいてコードの静的分析が行われ、必要な依存性のみがイメージに追加されます。そのため、イメージのサイズが必要最小限に抑えられます。
 

Fn で GraalVM を使用する

Fn CLIで次のコマンドを実行すると、Fn Projectでネイティブ・イメージを使うように設定することができます。


$ fn init --init-image fnproject/fn-java-native-init graalfunc

これで、Dockerfileを含む必要なリソースが作成されます。GraalVMで生成される実行可能ファイルはプラットフォーム固有なので、ターゲット・プラットフォームでイメージを実行するには、同じオペレーティング・システムでコンパイルする必要があります。たとえば、Apple macOSやMicrosoft Windowsで作成した実行可能ファイルは、当社のファンクション・コンテナで使っているBusyBox Linuxイメージでは動作しません。Fnでは、マルチステージDockerビルドを使ってこの問題を回避しています。

必要なツールはコンテナにすべて搭載されているので、開発者のマシンには何もインストールする必要がありません。さらに、非常に便利だと筆者が思うのは、このビルドではリフレクション用の構成、Javaネイティブ・インタフェース(JNI)、動的プロキシ、ランタイム自体のリソース・ローディングがすでに提供されていることです。

ちなみに、ネイティブ・イメージを別のクラウド・プロバイダのサーバーレス・プラットフォームで試してみましたが、すべてを適切に設定するためには、かなりの時間がかかるでしょう。Fnなら、「Hello World」サンプルは追加構成を必要とすることなく、すぐに動作しました。Fnとのシームレスな統合を行えば、作業が相当楽になるのはそのためです。コールド・スタートも高速化でき、メモリ使用量も減ります。

それでは、GraalVMを使って先ほどの「Hello World」ファンクションを呼び出す場合と、もともとのバージョンを使う場合を比較してみましょう。ローカルでの測定結果では、GraalVMを使ったときのコールド・スタート時間は約30%減少し、720ミリ秒から476ミリ秒になりました。同時に、メモリ使用量は18 MBから1 MBと、およそ20分の1になりました。

最後の1滴までパフォーマンスを完全に絞り出したいのであれば、GraalVMでアプリケーションを実行して最適化データを収集し、後でネイティブ・イメージにコンパイルする際に役立てることもできます。
 

まとめ

当社のSAS Secure Cloud Coreマルチテナント・システムでは、ツールとフレームワークの選定において、移植性と安定性を最重要視しました。その結果、Javaがサーバーレス・アプリケーションのすばらしい選択肢であることがわかりました。

成熟したビルド・ツールやテスト・ライブラリ、コード・リポジトリがあるJavaでは、大規模アプリケーションのメンテナンス性を保つためのすばらしい環境が提供されます。API設計者は、静的な型付け、きめ細かなアクセス修飾子、モジュール・システムによって、動的言語の場合よりも綿密にAPIを制御できます。

同時に、そういったAPIは、APIユーザーにとってのガイドにもなり、意図しない誤用を防いでくれます。さらに、言語に組み込まれたサービス・インフラストラクチャを含む、シンプルな階層化と抽象化によってビジネス・コードの移植性を保つ方法も提供されます。

Fnは、サーバーレス・プロジェクトの移植性と特定のクラウドに依存しない状態を保つ優れた手法です。このフレームワークでは、追加の依存性が紛れ込むことはないので、ソース・コードの移植性やテスト可能性が保たれます。ファンクションは、ホステッド・サービスにも、任意のクラウド環境にも、オンプレミスにもデプロイできます。GraalVMネイティブ・イメージとのシームレスな統合によってコールド・スタートの問題も解決され、メモリ・フットプリントも減少します。

Java、Fn、GraalVMは高度に一体化していながら、それぞれ独立して使用できます。どんな状況で使うのも自由です。

本シリーズの次回記事では、プラットフォームに依存しない、マルチクラウド・インフラストラクチャ管理ツールを導入して、このツールボックスを完成させたいと思います。

 

さらに詳しく