AWS のお得な機能だけでネイティブゲームサーバをつくる
昨今何かと話題に挙がってきた AWS Lambda と AWS DynamoDB を活用して格安で堅牢、高性能なゲームサーバを作ります。
既存システムの苦労をもとに、サーバの開発や運用を頑張らずにすむための仕組みとネイティブアプリからの AWS Lambda の利用方法を簡単に紹介します。
サーバが良くわからんという、ネイティブゲーム、ネイティブアプリエンジニアにオススメです。
※当内容は多分に個人主観を含んでおり、時事的な要素も含まれています。
※検索して十分な資料があると考えられるツールやライブラリの利用方法等は省略しています。
おさらい
まずは既存のゲームサーバの構成を初歩からおさらいしてみましょう。
簡単にサービスする方法としてアプリからのリクエストを受ける HTTP のサーバと利用者の情報を格納しておくデータベースが考えられます。しかし、すぐに思いつくだけでもいくつかの問題があります。
- 通信が暗号化されておらず、利用者を危険にさらしてしまう
- 四六時中ダウンしていないか気になってしまう
- サーバ更新のときや、データ変更などで頻繁にメンテ入りしてしまう
- 色々詰め込まれ、暗黙知まみれになってしまう
この程度でもサーバ二台は必要です。復旧もたいへんです。とても実用に耐えられません。クラウドの一番安いインスタンスを選んでも毎月千円前後でしょうか。
※ 2015年10月しらべ、最小インスタンスの月額。
- Google Compute Engine, f1-micro, $4.04 (¥480) https://cloud.google.com/compute/pricing
- IDCF Cloud , S1, ¥500 (¥540) http://www.idcf.jp/cloud/price.html
- さくらのVPS, 512, ¥635 (¥685) http://vps.sakura.ad.jp/specification/
- ConoHa by GMO, ¥900 (¥972) https://www.conoha.jp/pricing
- AWS EC2, t2.micro, $14.4 (¥1713) https://aws.amazon.com/jp/ec2/pricing/
そして効率よくサービスするために最低でも次のような構成が考えられます。
- 正しいドメインを取得し、 SSL 通信を利用して利用者を守る
- Load Balancer を構築し、サーバの冗長性を保ち、安全にデプロイする
- 各システムの異常を知らせるための監視システムを設置し、障害時間を短縮する
- データベースのバックアップやレプリカを運用し、安全にスケールしやすいようにする
スケールアウトしやすくサービスの人気に応じて品質を高めやすく、こういった構成は多いのではないかと思われます。
費用としては、ロードバランサーとデータベースは単一障害点とし、DNS は他社サービスを利用しても6台分と SSL 証明書、ドメインネーム代が発生します。もろもろで毎月一万円ぐらいかかりそうです。
わずかなアプリケーションでもそれなりに費用がかかり、その効率がよくなるには数十台~100台ほどの規模が良さそうです。
理想ではインスタンスもところどころ強化し、インフラ代だけで数百万円規模に膨らみ、相対的に運用システムのコストを抑えることです。
※ そして、このころにはデプロイの高速化やサービス全体の最適化、賢い構成管理、運用自動化、分割の戦略も課題になっているころです。
しかし、ネイティブアプリはもちろん、ウェブアプリですらリッチクライアント化してきており、アプリケーションサーバの役割は減ってきています。従来のようにページ毎に状態をすべて集め、 HTML テンプレートにあてはめて出力することはなく、必要なときに必要な状態だけをクライアントは取得し、保持し、クライアントにて画面を構成させ、ゲームの進行をします。
デプロイやモニタリング等のシステムを一式整備して揃え、そのためのアプリケーションサーバを構築したとしても、ネイティブアプリではたかだか数台で賄えてしまう可能性があり、それにしてはシステムを運用するためのオーバーヘッドが大きいのは明らかです。
Lambda と DynamoDB とネイティブアプリについてのもくろみ
おさらいができたところで現実的なところを見てみます。
先ほどまでのサーバ構成についてはいわゆる IaaS レベルを想定しており、運用者はサーバ OS の管理者となり、自由に設定やミドルウェアの導入等ができます。情報も多く、対応できるインフラエンジニアも比較的多くいます。
対して、 PaaS や SaaS、BaaS ではより高レベルな開発、運用ができますが、えてして *aaS はサーバ開発関連の話で、サービスも多く、情報が散逸してしまい、ネイティブアプリ開発者からしてみたら興味外ではないかと思われます。ネイティブアプリ向けでは API や SDK 等が揃っていてサーバ側の開発を不要とするものもありますが、一気に自由度が失われてしまい、ちょうど良さそうなものを探し、習得するのに苦労します。
しかし、そういった中でも Lambda はネイティブアプリと相性が良いと考え、その理由を以下のように考えています。
- 関数単位でプログラムを書くことができ、最小のコードはとても小さくで済む
- Web フレームワークっぽさが全くなく、いわゆる CGI よりも動作を把握しやすい
- デプロイの工夫などが必要なく、モニタリング等も揃っている。モニタリング対象も少ない
- 関数の入出力が原則 JSON であるが、 HTML を考えなくて良いのでネイティブアプリであればむしろ好都合
- Lambda には状態を保持することができず、キャッシュ等の仕組みも無いが、ネイティブアプリ側で対応できる
- コマンドラインからプログラムを実行するのと大差無く、ベンダーロックインしない。 Lambda を利用しなくても容易に別の環境で動かすことができる
- 最小設定ではひとつのクエリにて 128M までのメモリしか使えないが、昨今の HTTP を処理するスレッドと比較しても引けを取らない
- HTTPd では REMOTE_ADDRESS とされるアクセス元のアドレスが取得できるが、スマホアプリはキャリア NAT のアドレスになることが多く、一意に識別することはできない
そして DynamoDB と組み合わせた場合は、
- おおむね Key Value Store でスキーマがゆるく、いわゆるオブジェクト指向によるデータ構造と相性が良い
- リクエストやレスポンスが心なしか遅いが、ネイティブアプリでは非同期かつクエリの粒度を変えやすい
- リレーショナルモデルは扱いにくいが、ネイティブアプリではソーシャル要素が薄い傾向にある
- DynamoDB Stream と組み合わせて他の Lambda をバッチ処理的に扱える
- nodejs や python 環境があれば DynamoDB Local を利用してすべてオフライン環境でも開発できる
と、それぞれの利点を活かし、欠点をネイティブアプリの特徴で回避できるため相性が良さそうです。もちろん、暗号化通信に対応済みの SDK もあり、アプリから安全に直接 Lambda 関数を呼び出せます。
全体的にシンプルになりつつも、高い安定性と拡張性を獲得できそうです。AWS でも EC2 関連は比較的高額ですが、驚くことに他のサービスは非常に安く設定されています。
- https://aws.amazon.com/jp/lambda/pricing/ 100 万回まで無料
- https://aws.amazon.com/jp/dynamodb/pricing/ 読み書き 25 ユニットまで無料
利用計画
それぞれの動作確認を行いつつセットアップしていきます。
- SDK のビルドと組み込み
- ネイティブアプリから Lambda の呼び出し確認
- Lambda から DynamoDB の読み書き確認
- 既存サーバからの移行
SDK のビルドと組み込み
SDK は C++11 で書かれており、 CMake によってビルドします。すでにアプリを CMake でビルドしていた場合はとても簡単に組み込めますが、そうでない場合は単独でライブラリをビルドし、バイナリをリンクする手間がかかります。
SDK には AWS の他のサービスの API も存在するため、考えなしにビルドするとえらい時間がかかります。
※ https://github.com/awslabs/aws-sdk-cpp の各ディレクトリ
ビルドは CMake なので容易ですしカスタマイズもできるので Android も Linux としてビルドするのがオススメです(理由は後述)。
toolchain ファイルは aws-sdk-cpp 内にもありますが、ネイティブアプリ本体と合わせるため別途、
https://github.com/taka-no-me/android-cmake.git こちらを利用します。
Windows 以外の OS では cURL と OpenSSL, zlib に外部依存してます。
cURL と OpenSSL のビルドは大変面倒ですが、 https://github.com/gcesarmza/curl-android-ios を利用するのが比較的簡単です。
if(PLATFORM_ANDROID) の場合、外部依存リポジトリを git clone してきてビルドしてしまうので注意します。
aws-sdk-cpp とアプリ本体で別々バージョンの cURL と OpenSSL をリンクするとえらい容量になるのでアプリ本体の cURL と OpenSSL は共有させます。
集めるもの
- git clone https://github.com/awslabs/aws-sdk-cpp.git
- git clone https://github.com/gcesarmza/curl-android-ios.git
- wget http://zlib.net/zlib-1.2.8.tar.gz
- git clone https://github.com/taka-no-me/android-cmake.git # Android ビルド用
CMakeLists.txt を編集し、必要な部分だけ取り込む
- https://github.com/awslabs/aws-sdk-cpp/blob/master/CMakeLists.txt#L375
- add_subdirectory における、 aws-cpp-sdk-core と aws-cpp-sdk-lambda 以外をすべてコメントアウト
- CMake のオプションに渡すのが意外と手間になるので設定をハードコードしてしまっても良さそう
ネイティブアプリの場合わずかなパッケージ容量を抑えるために aws-sdk-cpp に付属しているライブラリを使うのもおすすめです。
※ https://github.com/awslabs/aws-sdk-cpp/tree/master/aws-cpp-sdk-core/include/aws/core/utils
すでに CMake を使っている場合、CMakeLists.txt に add_subdirectory(aws-sdk-cpp) や include_directories() を加え、アプリと一緒にビルドします。
ライブラリを単独でビルドする場合、 README.md の通りオプションを設定してビルドします。
- -G オプションによるビルドツールの選択( XCode,VisualStudio,Makefile)
- Android の場合、 -DCMAKE_TOOLCHAIN_FILE と -DANDROID_ABI(armv7,8,x86) -DANDROID_TOOLCHAIN_NAME(gcc4.8,4.9,clang) などの指定
- -DCMAKE_BUILD_TYPE=Release
- -DTARGET_ARCH=Linux
- 必要に応じて、 STATIC_LINKING=1 など
アプリのほうもビルドの確認を行います。だいたい次のヘッダにパスが通ってれば十分使えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#include <aws/lambda/LambdaClient.h> #include <aws/core/client/ClientConfiguration.h> #include <aws/core/auth/AWSCredentialsProvider.h> #include <aws/core/Region.h> #include <aws/lambda/model/InvokeRequest.h> #include <aws/lambda/model/InvocationType.h> #include <aws/lambda/model/InvokeResult.h> #include <aws/lambda/LambdaErrors.h> #include <aws/lambda/LambdaErrorMarshaller.h> #include <aws/core/utils/Outcome.h> #include <aws/core/utils/logging/AWSLogging.h> #include <aws/core/utils/logging/ConsoleLogSystem.h> #include <aws/core/utils/base64/Base64.h> #include <aws/core/utils/Array.h> |
ビルドが上手く行かない場合、 make VERBOSE=1 を設定するとビルドコマンドがそのまま表示されます。 CMakeLists.txt 内に message 関数を利用して、いわゆる print デバッグで不適切なパラメータを調べるのもお手軽です。
CMakeLists.txt を編集を維持しつつ SDK を更新するときは次のように rebase していきます。
1 2 3 |
git fetch git commit -am "my edit" git rebase origin/master |
Lambda の準備とネイティブアプリから呼び出し確認
Lambda function を起動するための、アプリケーション向けのユーザを作り、
呼び出されるための Lambda functoin を準備します。
- AWS マネジメントコンソールにログインします
- ヘッダのタブを Edit し、 IAM と Lambda と DynamoDB を加えておきます
- IAM を開き、 Users → Create New Users を選択します
- Credential 情報をメモしておきます
- Policy → Create Policy を選択します
- Policy Generator を選び、
- AWS Service に AWS Lambda
- Actions より Invoke Function
- AWS は * の一文字をいれ Add Statement を押します
- Next Step → Policy Name を分かりやすく invoke-Lambda などとして、 Create Policy します。
- 先ほど作った User を選択し、 Attach Policy ボタンを押します
- そして作成したポリシー名、 invoke-Lambda などを選択して設定します。
ここまでの手順で、このユーザの Security Credentials を知っている場合、インターネット中のどこからでも Lambda を呼び出すことが出来ます。そしてアプリケーションにこの鍵を埋め込んでしまうため、流出しても良いように余計なポリシーを付与しないようにしましょう。
- Create a Lambda function を押し、 simple-mobile-backend を選びます
- Ranking などと function 名を設定し、 Role から * Basic with DynamoDB を選びます
- 一時的に IAM Role を作る画面になるのでその場で作ってしまいます
- 他は特に設定することなく、 function 作成まで行います。
- function が出来たら念のため、 switch 文内の dynamo.* 操作をコメントアウトしておきます。
- Save and Test を行い、 Template は Mobile Backend で試します。
- 実行結果は Cloudwatch に保存されるため、ページ下部のリンクから Cloudwatch を確認しにいきます。
ここまでで AWS 側の設定は完了です。 AWS 関連の資料は幸いにも大量にあるため、検索すれば画像つきでの解説もでてきます。
つぎにアプリから呼び出してみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// Trace レベルでログはたくさん出力 const std::shared_ptr<Aws::Utils::Logging::LogSystemInterface> logr(new Aws::Utils::Logging::ConsoleLogSystem(Aws::Utils::Logging::LogLevel::Trace)); Aws::Utils::Logging::InitializeAWSLogging(logr); // 作成した IAM User の鍵を記述 Aws::Auth::AWSCredentials cred("****************CYDQ", "****************************43WX"); Aws::Client::ClientConfiguration config; config.region = Aws::Region::AP_NORTHEAST_1; //config.verifySSL = false; // OS にそなわる証明書が読み込まれない場合はひとまず無視 Aws::Lambda::LambdaClient lambda(cred,config); Aws::Lambda::Model::InvokeRequest invoke; invoke.SetFunctionName("Ranking"); invoke.SetInvocationType(Aws::Lambda::Model::InvocationType::Event); invoke.SetBody("{\"operation\": \"echo\", \"message\": \"Hello Lambda!\"}"); lambda.Invoke(invoke); |
僅かこれだけです、他の AWS SDK と比較しても記述量や機能に大差ありません。
まずは PC でビルドして PC からアクセスできるか確認した方が良いでしょう。
呼び出されたかどうかの確認は Cloudwatch の時刻で分かります。また呼び出し回数は Metrics に含まれているのでそれを参照するのも良いと思います。
失敗しやすいポイントはライブラリの組み込み周りで、
- リポジトリは aws-sdk-cpp だがライブラリ名は aws-cpp-sdk や aws-cpp-sdk-lambda となる
- cURL のクライアント証明書パスが合わなく、 aws-sdk には設定箇所が無い。 curl ビルド時にも設定できる
- スレッドセーフではない(と思う)
Lambda から DynamoDB の読み書き確認
※ 10月現在、 DynamoDB は新しい GUI と従来の GUI の両方が扱えますが従来の GUI を想定しています。
- マネジメントコンソールより DynamoDB を選ぶ
- Table Name は Ranking
- Hash Attribute Name には player
- Range Attribute Name には score
- あとは初期設定のまま Continue していき、 Use Basic Alarms のチェックははずし、 Create
JavaScript のコードから
1 2 3 4 5 6 7 |
var params = {}; params.TableName = "Ranking"; params.Item = { player: {S:event.player}, score: {N:''+event.score} }; dynamo.putItem(params, function(err,data){}); |
として、 event にプレイヤー名とスコア数値を渡せば Dynamo にレコードが増えるのを確認します。
より詳しい使い方は SDK に書いてありますが、 SQL よりは仕様が薄く、シンプルに扱えます。
- http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB.html
- 下位互換のためであったり、廃止予定の機能が残っているので注意深く利用しましょう。
既存サーバからの移行や互換性
たとえばフルスタック系 HTTPd のサービスでは URL のパースや application/x-www-form-urlencoded 等でパラメータを渡しているかと思われます。すでに application/json だった場合はしめたものです。 Body の JSON がそのまま Lambda の handler の第一引数に渡ります。
1 2 |
exports.handler = function (event, context) { console.log('Received event:', JSON.stringify(event, null, 2)); |
あとは通常の Web サーバと同じくパラメータの型や値の検証を経て、処理していきます。先に作った DynamoDB のテーブルをリアルタイムランキング風に扱うとすると、整合性は諦めて、次のようにデータを更新することができます
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
dynamo.query({ // 自身のハイスコアが無いかチェック TableName: "Ranking", KeyConditions: { player: { ComparisonOperator: 'EQ', AttributeValueList: [{ S: event.player }] }, score: { ComparisonOperator: 'GE', AttributeValueList: [{ N: '' + event.score }] } }, Limit: 1, }, function (err, data) { if (!err && data.Count == 0) { dynamo.putItem({ // ハイスコアを追加 TableName: "Ranking", Item: { player: { S: event.player }, score: { N: '' + event.score } } }, function (err, data) { }); dynamo.query({ // 古いスコアを検索 TableName: "Ranking", KeyConditions: { player: { ComparisonOperator: 'EQ', AttributeValueList: [{ S: event.player }] }, score: { ComparisonOperator: 'LT', AttributeValueList: [{ N: '' + event.score }] } }, }, function (err, data) { if (!err) { for (var n = 0; n < data.Items.length; n++) { dynamo.deleteItem({ // 古いスコアを削除 TableName: "Ranking", Key: { player: data.Items[n].player, score: data.Items[n].score }, }, function (err, data) { }); } } }); } }); |
アプリの動作と並列してデータの保存を行うだけであればとても簡単です。ランキングのスコア登録や、オンラインバックアップにはこの手法でだいたい対応できます。
当サンプルの score に range key を設定しなければ dynamo.updateItem による部分アップデートの対象にもできます。また、 DynamoDB を便利に扱う npm も結構あるので、慣れてきたら利用していくのが良さそうです。まずは、面倒ですが DynamoDB の API をそのまま使うほうが理解度を高めやすい印象でした。
アイテムの一部だけ更新
アイテムの一部だけ更新するときは getItem → 修正 → updateItem の流れになりますが、並列に更新が行われると両方の書き込みのうち、片方の更新が失われてしまいます。 getItem のパラメータに ConsistentRead: true
を設定し、 updateItem のパラメータの AttributeUpdates に更新後のアイテムを、 Expected に取得したアイテムそのままの状態を渡します。こうすることで更新しようとしたフィールドに変更があれば更新に失敗し、それを検出することができます。
いわゆる楽観ロックはお手軽にできます。 Lambda はその名の通り、他の言語のラムダ関数と同じく与えられた DynamoDB の状態以外に副作用を及ぼす時はこの手は使えません。
二重処理防止
updateItem するときに条件を付ける方法は先と同じで、 Boolean のフィールドを利用します。このフィールドは初期値が false とし、もとが false であることを期待しつつ true の値を書き込みます。こうすることで処理が並列に動いてしまっても、先に書き込めた方が成功になり、片方はエラーになるので、二重処理を防げます。
このように悲観ロックもできます。もちろん、ロック解放漏れに気を付ける必要があります。
トレードなど
悲観ロックを複数のレコードに対して行う事で、片方の値を減らし、減らした分だけもう片方の値を増やすなど、他のプレイヤーとのトレードもできます。ただし DynamoDB はシャーディングされているため、一意に処理順を決定させることがおそらくできなく、二層ロックのようなもので回避するのは難しいと考えられます。また、途中で処理が止まってしまう事もあり、処理時間と比例して利用料が課金されることもあり、タイムアウト処理は厳しめに設定したほうがよいでしょう。
より活用していくには
- SDK はスレッドセーフでなさそうなので SDK 専用スレッドとメインスレッドをキューで繋ぎ、非同期化
- cURL のコンテキストに注意する
- CloudFormation で IAM の細かい管理
- AWS CLI でのデプロイ
- ボトルネックや特殊な処理を VPC 内の EC2 へ移行、 Web 系インスタンスとの共存
- 静的な状態を S3 に設置
- 実行ログやメトリクス選定と CloudWatch の利用
- nodejs , python と DynamoDB Local でオフライン開発環境の整備
モヤモヤ感
- aws-sdk-cpp のバイナリがおもいのほか大きい。 .so にて 10M
- 複数の Lambda Function を atomic に更新するには工夫が要る
- 更新直後は動作が遅い。通常数 ms で実行が終わるところが、数百 ms にもなることもある
- SQL が人間に優しい言語とすると、 DynamoDB の書き方は機械に近く、そのパラメータは抽象構文木にも見えてくる
- DynamoDB でもリアルタイムランキングといった用途にはあまり向いていない
まとめ
とても安く始めることができ、実装量も少なく、安定性が非常に高く、拡張の余裕も十分にあり、運用の手間がかかりません。ネイティブアプリにオススメ。