新しい IBM Developer JP サイトへようこそ!サイトのデザインが一新され、旧 developerWorks のコンテンツも統合されました。 詳細はこちら

Docker コンテナー内での OpenJ9 クラス共有

当初、2000 年代初期のモバイル・デバイス上で稼働するように設計された OpenJ9 は、クラウド向けの Java 仮想マシンです。JDK8 Hotspot と比べ、OpenJ9 は約半分のメモリー使用量で同等のスループットを達成します。この優れたパフォーマンスは OpenJ9 に元々備わっているものですが、さらに調整する余地はあります。この記事では、OpenJ9 をコンテナー化された環境内で実行する際に OpenJ9 のクラス共有機能を有効にする方法を説明します。

OpenJ9 のクラス共有機能に馴染みがない場合は、このリンク先のチュートリアル「Eclipse OpenJ9 内のクラス共有」(IBM Developer、2018 年 6 月) でクラス共有の仕組みと、この機能を使用すべき理由を詳しく説明しています。このチュートリアルの説明を要約すると、クラス共有を有効化することで、OpenJ9 JVM が Java コードをコンパイルして最適化を行い、その情報を他の OpenJ9 JVM でも使用できるように、共通の場所にキャッシュするようになります。クラス共有は、起動時間の短縮、CPU とメモリー使用量の削減を含め、かなりのメリットをもたらします。

コンテナー化された環境内でなければ、起動スクリプトに JVM 引数 -Xshareclasses を追加し、後は OpenJ9 のデフォルト設定に任せるだけで、OpenJ9 のクラス共有機能を使用できるようになります。一方、コンテナー化された環境内では、クラウド内で Java アプリケーションを実行する場合によくあるように、ある程度の追加作業が必要になります。コンテナー化された環境内で OpenJ9 のクラス共有をセットアップするには 2 つの手法があります。それぞれの手法の利点と欠点を比較しましょう。

手法 1: Docker ボリュームを使用する

Docker ボリュームを共有クラス・キャッシュとして使用する手法は、このリンク先の OpenJ9 Docker ページで説明しているとおりです。次のようにかなり単純な手順で、Docker ボリュームを使用するように共有クラスをセットアップできます。

1.ボリュームを作成します。

私は次のコードを使用してボリュームを作成しました。

docker volume create java-shared-classes

1.Dockerfile 内の CMD または ENTRYPOINT のいずれかを使用して、クラス共有を有効化し、クラス情報の保管先を明示的に定義する必要があります。

ENTRYPOINT ["java", "-Xshareclasses:cacheDir=/cache", "-Xscmx300M", ...]

-Xshareclasses はいくつかのサブオプションを取ることができます。そのうちの 1 つ、cacheDir を使用することで、クラス・データを保管するディレクトリーを明示的に定義できます。また、必要に応じてキャッシュのサイズを定義することもできます。上記の例では、キャッシュのサイズに余裕を持たせ、300MB とかなり大きめなサイズを設定しています。

1.Docker コンテナーの実行時にボリュームをマウントする必要があります。それには、Docker RUN コマンドのスクリプト内に次のコードを追加します。

docker run --mount source=java-shared-classes,target=/cache <image name>

重要な点として、target で指定するディレクトリーは、Dockerfile 内の cacheDir で定義したディレクトリーと同じでなければなりません。

注: 共有クラス・キャッシュをどれくらいの大きさにするかは、次の考慮事項を含め、いくつもの要因によって左右されます。

  • 同じ Java アプリケーションを実行するコンテナーの間でのみキャッシュを共有するのか。
  • Java アプリケーションを実行する任意の Docker コンテナーでキャッシュを使用するのか。
  • Java アプリケーションの間にはどの程度の類似性があるのか。

共有クラス・キャッシュを実行および保守するユーティリティー・メソッドについては、このリンク先の -Xshareclasses に関する OpenJ9 ユーザー・マニュアルを参照してください。

手法 2: Docker コンテナーを「事前ウォームアップ」する

Docker コンテナーを「事前ウォームアップ」するという手法は、同僚の Mike Thompson が紹介してくれました。彼のオリジナルのコードは、このリンク先の GitHub 上に置かれています。

Docker コンテナーの「事前ウォームアップ」は、コンテナーが実行することになる Java アプリケーションを、Docker イメージのビルド中に実行することによって行います。-Xshareclasses を指定して Java アプリケーションを実行すると、あらかじめデータを取り込んだキャッシュを Docker イメージ内に格納することができます。後で Docker イメージが実行されるときには、そのイメージが実行する Java アプリケーションを既存のキャッシュから取得できます。この手法を使用するには、次の Docker RUN コマンドを実行します。

RUN /bin/bash -c 'java -Xshareclasses -Xscmx20M -jar batch-processor-0.0.1-SNAPSHOT.jar --run_type=short &' ; sleep 15 ; xargs kill -1

RUN コマンドを設計する際に考慮しなければならない点はいくつもあります。それについては後で詳しく説明するとして、まずは Docker ボリュームを使用した場合と Docker コンテナーを事前ウォームアップした場合のパフォーマンスを比較しましょう。

パフォーマンスの比較

上述の 2 つの方法で -Xshareclasses を使用した場合それぞれのパフォーマスを比較するために、OpenJ9 のプレゼンテーション用に作成したデモ・アプリケーションを使用しました。このデモは、200 個のレコードに対して変換を行う Spring Batch プロセスを実行する Spring Boot アプリケーションです。このアプリケーションの処理内容について詳細を調べるには、このリンク先のプロジェクトの README を参照してください。

デモでは次の 3 つのコンテナーを順に実行します。

  • 「cold」コンテナー。この Docker コンテナーは、クラス共有が有効にされていない状態でデモを実行します。
  • 「volume」コンテナー。この Docker コンテナーは、外部ボリュームを使用してクラス・データを保管します。
  • 「warm」コンテナー。この Docker コンテナーは、クラス・データで Docker イメージを事前ウォームアップする上述の手法を使用します。

各イメージを最大 30 回実行して実行時間と最大メモリー使用量を収集しました。その結果が、以下の図に示されています。図 1 と図 2 を調べて、クラス共有の手法をどのように対比できるか把握しましょう。

図 1 は、バッチ・アプリケーションの実行にかかった時間を示しています。

約 30 回実行を繰り返して各 Docker コンテナーの実行所要時間を収集した結果を比較する線グラフ

図 1.最大 30 回実行を繰り返して各 Docker コンテナーの実行所要時間を収集した結果を比較する線グラフ

「warm」と「cold」の両コンテナーでは、実行時間が何回か急増しています。けれども傾向線に見られるように、全体的としてはかなり一貫したパフォーマンスになっています。どちらもコンテナーにネイティブなものだけを使用しているので、これは当然の結果です。ここで注目に値するのは「volume」コンテナーです。

デモでは、出発点として空のキャッシュを使用しました。つまり、「volume」コンテナーは「cold」コンテナーのようにクラスをコンパイルしなければならないだけでなく、それらのクラスをキャッシュに書き込む必要もあります。図 1 のグラフには、その結果が大幅なスループットの減少として表れています。「cold」コンテナーは一貫して約 9 秒で実行を完了するところ、「volume」コンテナーでは最初の実行を完了するのに 13 秒近くかかっています (注: 上記の図には示されていませんが、この図の結果を検証するために、キャッシュを空にした状態で「volume」コンテナーを起動するシナリオを何回か実行しました)。

ただし、「volume」コンテナーは最初の実行にはかなりの時間がかかるものの、2 回目以降は「warm」コンテナーと同等の実行速度になっています。「warm」コンテナーはビルドされるたびにバッチ・アプリケーションを 1 回実行するため、これは納得の行く結果です。

最終的に、各「volume」コンテナーをさらに何度も実行することになりました。「cold」と「warm」のパフォーマンスはかなり一貫している一方で、「volume」コンテナーは実行するごとに少しずつそのパフォーマンスを向上させていきます。その理由は、コンテナーが実行されるたびに、OpenJ9 によってクラス情報が追加され、JIT 最適化がキャッシュに適用されるためです。最初の実行によって、かなりのメリットがもたらされています。また、3 回目 (実行によってもたらされるメリットが頭打ちになった後) のコンテナー実行時にも気付くほどのパフォーマンス向上が記録されています。「warm」手法と比べ、「volume」手法は経時的にパフォーマンスが向上していきました。

図 2 のグラフは、コストと欠点を比較して分析していますが、まずは別のパフォーマンス要因としてメモリー使用量に目を向けましょう。

それぞれのイメージでのメモリー使用量の差をデモするために、箱ひげ図を使用しました。毎回の Docker コンテナーの実行によって使用されたメモリー量の最大値と最小値 (つまり、最小最大値) を「ひげ」で表し、メモリー使用量の中間の 50% を「箱」で表しています。この図から、デモの実行時に発生した外れ値だけでなく、通常期待できるメモリー使用量も把握してもらえたらと思います。そうとは言え、図 2 のグラフを分析しましょう。

各 Docker コンテナーのメモリー・フットプリントを比較する箱ひげ図 図 2.各 Docker コンテナーのメモリー・フットプリントを比較する箱ひげ図

クラス共有を有効にすると、メモリー使用量が顕著に減少します。クラス共有を使用しない「cold」コンテナーは、平均で約 140MB、最大では 240MB のメモリーを使用し、メモリー使用量の多さにかけてはトップに躍り出ています。「warm」コンテナーと「volume」コンテナーのメモリー使用量は約 125MB で、150MB を超えることはありませんでした。

2

つの手法の比較

一方の手法がもう一方の手法よりも明らかに優れているということはありません。どの手法を組織で使用するかを判断するときには、考慮しなければならない要素がいくつもあります。こうした要素の中には、上記のグラフでは十分に明かにならず、ローカル・マシン上でのテストからでしか把握できないものもあります。実際のシナリオで 2 つの手法がどのように作用するかについて理解を深められるよう、手法を選ぶ際に考慮すべき要素を見ていきたいと思います。

私が第一に取り上げたい要素は、レイテンシーに関するものです。私は上述のテストをローカル・マシン上で実行したので、Docker コンテナーが実行されているのと同じハード・ドライブ上にボリュームを作成しました。クラウド環境内では、Docker コンテナーとボリュームが常に同じマシン上に存在するとは限りません。ボリュームの論理位置が物理的に分離したマシン上にある場合に追加されるレイテンシーは、アプリケーションの起動時間に顕著な影響を与え兼ねません。

事前ウォームアップしたボリュームを使用する場合にも、いくつかの考慮事項があります。まず、ボリュームに伴う追加のストレージ・オーバーヘッドです。私の場合、事前ウォームアップした Docker コンテナーのサイズは 390MB になりました。一方、「cold」および「volume」コンテナーのサイズは 369MB です。組織が Docker コンテナーとして維持するサービスの数、そしてイメージの保持期間によっては、ストレージの使用量が大幅に増加する可能性があります。そうは言っても、不要なものを適切に削除するという手法を取れば、この問題に対処できます。

考慮しなければならない要素としては、Docker コンテナーを適切に事前ウォームアップするための作業も挙げられます。私のデモでは、事前定義されたデータ・セットで Spring Batch ジョブを使用しているので、短縮バージョンのジョブを RUN コマンドで実行すれば済む話ですが、ほとんどのアプリケーションはバッチ・ジョブではありません。したがって、負荷をシミュレーションするために多少の作業が必要になります。負荷をシミュレーションしなければ、最初の起動時に利用できる、JIT によってキャッシュ内に入れられる最適化はそれほどの数にはなりません。

私は以上の要素のどれも、特に重要だとは思いません。これらの要素は、非常に大規模なアプリケーションや、極めて厳しいパフォーマンス基準を見なさなければならない場合には重要になってくることが考えられますが、比較的簡単に克服できる技術面の問題です。

まとめ

私は OpenJ9 について調べるや否や、このテクノロジーに魅了されました。メモリー使用量を大幅に削減し、しかも Hotspot に匹敵するパフォーマンスを実現する OpenJ9 は実にエキサイティングで、講演を行うにも記事を書くにも面白い話題です。

この記事で取り上げたクラス共有はさらにメモリー使用量の削減を促すとともに、-Xquickstart などの機能と組み合わせると起動時間を短縮します (詳しくは、このリンク先の OpenJ9 ユーザー・マニュアルを参照)。こうしたクラス共有により、OpenJ9 はサーバーレス関数という点に関して非常に興味深いものになっています。起動時間の短縮については今後の記事で取り上げる予定ですので、お見逃しなく。