サーバーレス・ファンクションのためのホットなデータベース接続 (2020/05/24)
https://medium.com/oracledevs/hot-database-connections-for-serverless-functions-9f9e8a681df6
投稿者:Kuassi Mensah
図: Autonomous Database(ATP-S)インスタンスに接続するOracle Functionインスタンス
前置き - 解決すべき課題
新しいサーバーレスコンテナの起動(コールドスタート)には、1秒から数秒かかる(時間はプラットフォームによって異なる)。このようなコスト・レイテンシーをなくすために、サーバーレスフレームワークでは、すでに起動したコンテナを一定期間温めておく(期間はプロバイダーによって異なる)。
サーバーレス・ファンクションでデータベースにアクセスすることがある。新規にサーバーレスコンテナを起動するよりはコストが低いものの、DBMS環境によっては、データベース接続の作成と削除に数十ミリ秒から数百ミリ秒のコストがかかる場合があります。この問題は、毎回の呼び出しにそのようなコストをかけられない短命なサーバーレス・ファンクションでは悪化します。
解決すべき最初の問題は、接続の作成と切断を避けることです。
接続をコンテナ間でプール/共有できないため、解決すべきもう1つの問題は、固定のデータベース接続数を前にして、自動スケーリングと何千ものサーバーレス・ファンクションの同時呼び出しという高い同時性の影響です。
この記事の議論、解決策、ベストプラクティスは、Javaをベースにしていますが、サーバーレスインフラストラクチャがサポートするすべての言語に適用できます(例えば、Oracle Functionsは、Java、Go、Node.js、Python、Rubyをサポートしています)。
コネクションの作成と削除の回避
既存の接続を再利用するのにかかる時間は数ミリ秒以下ですが、サーバーレス・ファンクションの各インスタンスは、単一のデータベース接続を持つ個別のコンテナで実行されるため、これらの接続をプールしてコンテナ間で共有することはできません。
幸いなことに、サーバーレスのコンテナは、ファンクションの呼び出しが終了すると数分間保持されます。さらに、サーバーレスのプログラミングモデルでは、呼び出しのたびに起動されるエントリーメソッドであるhandleRequest()の外側で宣言された実行コンテキストや状態を再利用することができます。
public class HelloFunction { |
private PoolDataSource poolDataSource; |
private UUID uuid = null; |
private final File walletDir = new File("/tmp", "wallet"); |
private final String namespace = System.getenv().get("NAMESPACE"); |
private final String bucketName = System.getenv().get("BUCKET_NAME"); |
private final String dbUser = System.getenv().get("DB_USER"); |
private final String dbPassword = System.getenv().get("DB_PASSWORD"); |
private final String dbUrl = System.getenv().get("DB_URL"); |
final static String CONN_FACTORY_CLASS_NAME="oracle.jdbc.pool.OracleDataSource"; |
...
クラスの状態は、handleRequest()メソッドの外側で宣言されています。
クラスのコンストラクタで単一のデータベース接続(サイズ1のプール)をキャッシュしておけば、コンテナが残っている限り、次のコールで再利用することができます。
コンテナごとのコネクションキャッシュ
uuid = UUID.randomUUID(); |
System.out.println("Setting up pool data source"); |
poolDataSource = PoolDataSourceFactory.getPoolDataSource(); |
poolDataSource.setConnectionFactoryClassName(CONN_FACTORY_CLASS_NAME); |
poolDataSource.setURL(dbUrl); |
poolDataSource.setUser(dbUser); |
poolDataSource.setPassword(dbPassword); |
poolDataSource.setConnectionPoolName("UCP_POOL"); |
poolDataSource.setInitialPoolSize(1); |
poolDataSource.setMinPoolSize(1); |
poolDataSource.setMaxPoolSize(1); |
System.out.println("Pool data source error!"); |
System.out.println("Pool data source done..."); |
}
クラスのコンストラクタで、handleRequest()メソッドの外側で、サイズ1のUCPプールをセットアップする
私のadb-ucp ファンクションの完全なJavaコードは、Todd Sharp氏の投稿「Oracle Functions - Connecting To An ATP Database」から引用しています。
import com.fasterxml.jackson.core.JsonProcessingException; |
import com.fasterxml.jackson.databind.ObjectMapper; |
import com.oracle.bmc.Region; |
import com.oracle.bmc.auth.ResourcePrincipalAuthenticationDetailsProvider; |
import com.oracle.bmc.objectstorage.ObjectStorage; |
import com.oracle.bmc.objectstorage.ObjectStorageClient; |
import com.oracle.bmc.objectstorage.requests.GetObjectRequest; |
import com.oracle.bmc.objectstorage.requests.ListObjectsRequest; |
import com.oracle.bmc.objectstorage.responses.GetObjectResponse; |
import com.oracle.bmc.objectstorage.responses.ListObjectsResponse; |
import oracle.ucp.jdbc.PoolDataSource; |
import oracle.ucp.jdbc.PoolDataSourceFactory; |
import org.apache.commons.io.FileUtils; |
import java.io.IOException; |
import java.util.ArrayList; |
import java.util.HashMap; |
public class HelloFunction { |
private PoolDataSource poolDataSource; |
private UUID uuid = null; |
private final File walletDir = new File("/tmp", "wallet"); |
private final String namespace = System.getenv().get("NAMESPACE"); |
private final String bucketName = System.getenv().get("BUCKET_NAME"); |
private final String dbUser = System.getenv().get("DB_USER"); |
private final String dbPassword = System.getenv().get("DB_PASSWORD"); |
private final String dbUrl = System.getenv().get("DB_URL"); |
final static String CONN_FACTORY_CLASS_NAME="oracle.jdbc.pool.OracleDataSource"; |
uuid = UUID.randomUUID(); |
System.out.println("Setting up pool data source"); |
poolDataSource = PoolDataSourceFactory.getPoolDataSource(); |
poolDataSource.setConnectionFactoryClassName(CONN_FACTORY_CLASS_NAME); |
poolDataSource.setURL(dbUrl); |
poolDataSource.setUser(dbUser); |
poolDataSource.setPassword(dbPassword); |
poolDataSource.setConnectionPoolName("UCP_POOL"); |
poolDataSource.setInitialPoolSize(1); |
poolDataSource.setMinPoolSize(1); |
poolDataSource.setMaxPoolSize(1); |
System.out.println("Pool data source error!"); |
System.out.println("Pool data source setup..."); |
public List handleRequest(String input) throws SQLException, JsonProcessingException { |
System.setProperty("oracle.jdbc.fanEnabled", "false"); |
String name = (input == null || input.isEmpty()) ? "world" : input; |
if( needWalletDownload() ) { |
System.out.println("Start wallet download..."); |
System.out.println("End wallet download!"); |
// Start timing connection establishment |
t0=System.currentTimeMillis(); |
Connection conn = poolDataSource.getConnection(); |
t1=System.currentTimeMillis(); |
System.out.println ("==> UUID = " + uuid + " Connection Establishment: "+ t + " Milliseconds"); |
conn.setAutoCommit(false); |
Statement statement = conn.createStatement(); |
statement.executeQuery("select table_name from all_tables where rownum < 10"); |
List<HashMap<String, Object>> recordList = convertResultSetToList(resultSet); |
System.out.println( new ObjectMapper().writeValueAsString(recordList) ); |
System.out.println("***"); |
private List<HashMap<String,Object>> convertResultSetToList(ResultSet rs) throws SQLException { |
ResultSetMetaData md = rs.getMetaData(); |
int columns = md.getColumnCount(); |
List<HashMap<String,Object>> list = new ArrayList<HashMap<String,Object>>(); |
HashMap<String,Object> row = new HashMap<String, Object>(columns); |
for(int i=1; i<=columns; ++i) { |
row.put(md.getColumnName(i),rs.getObject(i)); |
private Boolean needWalletDownload() { |
if( walletDir.exists() ) { |
System.out.println("Wallet exists, don't download it again..."); |
System.out.println("Didn't find a wallet, let's download one..."); |
private void downloadWallet() { |
// Use Resource Principal |
final ResourcePrincipalAuthenticationDetailsProvider provider = |
ResourcePrincipalAuthenticationDetailsProvider.builder().build(); |
ObjectStorage client = new ObjectStorageClient(provider); |
client.setRegion(Region.US_PHOENIX_1); |
System.out.println("Retrieving a list of all objects in /" + namespace + "/" + bucketName + "..."); |
// List all objects in wallet bucket |
ListObjectsRequest listObjectsRequest = ListObjectsRequest.builder() |
.namespaceName(namespace) |
ListObjectsResponse listObjectsResponse = client.listObjects(listObjectsRequest); |
System.out.println("List retrieved. Starting download of each object..."); |
// Iterate over each wallet file, downloading it to the Function's Docker container |
listObjectsResponse.getListObjects().getObjects().stream().forEach(objectSummary -> { |
System.out.println("Downloading wallet file: [" + objectSummary.getName() + "]"); |
GetObjectRequest objectRequest = GetObjectRequest.builder() |
.namespaceName(namespace) |
.objectName(objectSummary.getName()) |
GetObjectResponse objectResponse = client.getObject(objectRequest); |
File f = new File(walletDir + "/" + objectSummary.getName()); |
FileUtils.copyToFile( objectResponse.getInputStream(), f ); |
System.out.println("Stored wallet file: " + f.getAbsolutePath()); |
} catch (IOException e) { |
}
完全なサーバーレス・ファンクションのJavaコード。
Todd氏の投稿には、接続文字列やクラウドの認証情報など、Oracle FunctionsとATP-Sデータベースの設定、デプロイ、起動に関するすべての手順が記載されています(ここでは重複しません)。EMPLOYEESテーブルに対するクエリをALL_TABLE(ディクショナリーテーブル)に対するクエリに置き換えたことで、例を実行する前にテーブルを作成する必要がなくなりました。アプリケーションとファンクションの両方に「adb-ucp」という名前を付け、プールから接続を取得するまでの時間(getConnection()メソッドの呼び出し)を計測しました。
次のコマンドでは、ファンクションのデプロイと起動を行います。
fn deploy --app adb-ucp
fn invoke adb-ucp adb-ucp
adb-ucpのデプロイと起動
サーバーレス・ファンクションは返されたデータのみを表示するため、ログやプリントアウトを確認するには、Oracle Cloud Infrastructure Logging ServiceやPaperTrailなどのロギングサービスに加入する必要があります。
各コンテナにサイズ1のプールを設定することで、接続の作成と削除を回避することができますが、この場合、ファンクションインスタンスとデータベースセッションが1対1でマッピングされることになり、非アクティブ/ウォームなファンクションがデータベース接続を保持している可能性もあります。このようなソリューションは、小規模なデプロイメントではうまく機能しますが、大規模なデプロイメントでは、同時にアクティブまたはウォームなファンクションインスタンスの数が、利用可能なデータベースセッションの有限数よりも多くなるため、最終的には待ち時間や例外が発生します。
オートスケーリング/ハイコンカレンシーの維持
前文で述べたように、各コンテナに1つの専用接続のプールを設定すると、コンテナ間で接続をプール/共有することができません。コンテナとRDBMSセッションの間の1対1のマッピングでは、サーバーレス・ファンクションの高い並行性を伴う大規模なデプロイメントを維持することはできません。前提として、これらの関数のすべてが同時にデータベースにアクセスしているわけではなく、不要になったときには接続を放棄することになっていますが、そうでない場合は共有ができなくなります。
OracleのCMAN-TDM(Connection Manager in Traffic Director Mode、この機能は現在クラウドサービスではありません)やMySQL Routerなどのプロキシ接続サーバーや、OracleのDRCP(Database Resident Connection Pool)などのRDBMS側のコネクションプールなど、いくつかのソリューションがあります。
これらのソリューションに共通しているのは、ミドルティアやコンテナをまたいでデータベースセッションをプールする機能です。
サーバーレス・ファンクションとデータベース常駐接続プール
ATP-SではDRCPがデフォルトで有効になっています。クライアント、ミドルティア、コンテナは、必要に応じてアイドル状態のデータベース・セッションを提供するコネクション・ブローカーに「接続」されます。コネクションブローカは、リクエスタにアイドルのデータベースセッションを関連付ける際にループに留まることはなく、クローズしたコネクションはプールに戻されます。
DRCP - コンテナ間の接続の共有
ATP-Sデータベースには、5つのサービスプロファイルが用意されています。ReportingまたはBatch用のhigh、medium、low、OLTP用のtpおよびtpurgentです。これらのtnsnames.oraのエイリアスやエントリポイントはJNDI名に似ており、データベースサービスの詳細を隠したり仮想化したりします。
上記の1対1のマッピングで使用したのと同じOracle FunctionでDRCPを構成して使用する手順を説明します。
ofunctions_high = (...)
ofunctions_low = (...)
ofunctions_medium = (...)
ofunctions_tp = (...)
ofunctions_tpurgent = (...)
ATP-Sデータベース用に作成したtnsnames.oraのエイリアス(名前はOFunctions
- クラウドクライアントの認証情報をダウンロードし(Toddの投稿のステップ1「ATP Walletのダウンロード」を参照)、ローカルフォルダに解凍します。
- tnsnames.oraファイルを編集して、DRCPの参照を持つ新しいエイリアスを追加します(代わりに、5つのオリジナルのエイリアスの1つを変更しても構いません)。私のテストでは、ofunctions_tpurgentの記述を複製し、CONNECT_DATAセクションに「(SERVER =POOLED)」を追加して、ofunctions_tpurgent-DRCPという新しいエントリを作成し、tnnames.oraファイルを保存しました。
ofunctions_tpurgent-DRCP = (description= (retry_count=20)(retry_delay=3)(address=(protocol=tcps)(port=1522) |
(host=xxx.us-phoenix-1.oraclecloud.com) |
(connect_data=(service_name=xxx_ofunctions_tpurgent.atp.oraclecloud.com) |
(security=(ssl_server_cert_dn="...")) |
)
DRCPとtpurgentを使用するための新しいエイリアス
3. Toddの投稿の.ステップ2「Upload wallet to a Private Bucket in Object Storage」で説明されているように、更新されたtnsnames.oraを含めてウォレットをアップロードします(既に行っている場合は再度)。実際には、前のセクション/ソリューションでウォレットをアップロードする前にもこの変更を行っていたので(新しいエントリを追加)、再アップロードする必要はありませんでした。
4. 新しいエイリアスを使用するために、ファンクション設定のDB_URLを以下のように更新します。
fn config app adb-ucp DB_URL jdbc:oracle:thin:@ofunctions_tpurgent-DRCP?TNS_ADMIN=/tmp/wallet
このファンクションはすでにデプロイされているので、コンフィグに変更を加えると、新しいURLとDRCPがピックアップされます。次のステップでは、前のセクションと同様に、単にファンクションを起動します。各コンテナでサイズ1のプールを使用していますが、これらのプールはデータベース・セッションではなく、コネクション・ブローカーに接続されています。サイズ1のプールを単独で使用する場合と比較して、DRCPではミリ秒単位の待ち時間が発生します。これは、コネクション・ブローカーを経由するための代償ですが、同じATP-Sインスタンスに接続するすべてのコンテナにわたってデータベース・セッションをプールすることで、高い同時実行性を維持できるというメリットがあります。非DRCPの場合のように、ウォームコンテナがアイドル接続を保持することはありません。
まとめ
私は、前文で述べた解決すべき問題に対するソリューションを説明してきました。(i) ファンクションを呼び出すたびに接続の作成と切断を行わない、(ii) データベースセッションの数が有限であるにもかかわらず、高い同時実行性を維持する。
コンテナの人生で最初のデータベース接続要求が高い代償を払うことは明らかですが、それ以降の呼び出しは無料の昼食を得ることができます、というかほとんど!!。
コメント
コメントを投稿