電通総研のブログ - TECH PLAY

TECH PLAY

電通総研

電通総研 の技術ブログ

856

こんにちは!金融ソリューション事業部の孫です。 以前の 記事 では Unreal Engine Dedicated Serverの構築方法について紹介しました。 今回は続きの記事として、以下の3部で、 AWS が提供するEKSを使用してマッチメイキング機能を持つAgonesでGameServerを運用する環境の構築プロセスを説明します。 なお、EKSは Kubernetes の クラウド サービスの一つであり、同様のものとして「 Google のGKE」や「 Microsoft の AKS 」などが存在します。 以下3記事(Part)に分けて解説します。 Part1 :環境設定(「EKS環境構築」と「Agones/Open Matchのインストール」)、および動作確認 Part2 :マッチメイキングサービスのカスタマイズ処理を実装します Part3 :UnrealEngineを使用したGameServerとGameClientを用意します、Part2で開発したマッチメイキングサービスをGameClientに統合します。 はじめに 実行環境 実施手順 1.EKSの環境構築 2.Agonesのインストール 3.Openmatchのインストール 4.動作確認 Agonesデモのデプロイと動作確認 Open Matchデモのデプロイと動作確認 終わりに 参考 はじめに 現代のゲーム業界では、Dedicated Serverが重要な要素となっています。ある マルチプレイ ゲームの裏側では、大量のDedicated Serverを所有しており、プレイヤーに安定かつ効率的なサービスを提供します。 これらのサーバーの管理とスケジューリングは、プレイヤーのゲーム体験に直接影響する重要な問題です。この問題を解決するために、Agonesは登場し、Dedicated Serverの管理とスケジューリングをよりシンプルにするソリューションを提供します。 しかし、専用サーバーの管理とスケジューリングだけでは不十分で、これらのサーバーを効率的に利用するためにはマッチングシステムが必要です。 例えば、同じ地域のプレイヤーが最も近いゲームサーバーにマッチングされることで、彼らのゲーム体験を向上させることを望んでいます。そのため、Open Matchは、柔軟でスケーラブルなマッチングシステムとして生まれました。 したがって、AgonesとOpenMatchは相互補完的な存在です。一方で、AgonesはDedicated Serverの管理とスケジューリングを行い、サーバーの使用効率を向上させます。 一方で、Open Matchは効率的なマッチング アルゴリズム を通じて、プレイヤーを最も適したサーバーにマッチングします。 これら二つは、ゲームに対して柔軟でスケーラブルで効率的なインフラスト ラク チャを提供します。 これから、このようなインフラスト ラク チャを作成する方法を3記事に分けてステップ バイス テップで説明します。 実行環境 kubectl (v1.27.1) Kubernetes クラスタ ーを操作するための コマンドライン ツール eksctl (0.139.0) EKS クラスタ ーを制御するために使用する コマンドライン ツール AWS CLI (2.11.17) Amazon EKS など AWS のサービスを操作するための コマンドライン ツール 実施手順 以下の手順で環境を構築し、テストを行います。 EKSの環境構築 Agonesのインストール Openmatchのインストール 動作確認 1.EKSの環境構築 EKSの構築方法については、 AWS 公式ドキュメントの 「Amazon EKS の開始方法」 に従って以下の手順を実施します。 EKS展開用のネットワーク環境を作成する Kubernetes クラスタ を作成する クラスタ のノードグループを作成する これから、EKSの構築を実施します。 a.EKS展開用のネットワーク環境を作成する 今回は検証目的のため、 公式サイト で推奨されるネットワーク アーキテクチャ (PrivateとPublicの構造)は使用せず、2つのPublicネットワークのみを設定します。 実施結果を確認します。 VPC CIDR: 192.168.0.0/16 Subnet: $ aws ec2 describe-subnets \ --filters "Name=vpc-id,Values=[VPC ID]" \ --query 'Subnets[*].{SubnetId: SubnetId,AvailabilityZone: AvailabilityZone,CidrBlock: CidrBlock}' \ --output table -------------------------------------------------------------------- | DescribeSubnets | +------------------+------------------+----------------------------+ | AvailabilityZone | CidrBlock | SubnetId | +------------------+------------------+----------------------------+ | ap-northeast-1a | 192.168.0.0/24 | パブリックサブネットID1 | | ap-northeast-1c | 192.168.1.0/24 | パブリックサブネットID2 | +------------------+------------------+----------------------------+ b. Kubernetes クラスタ を作成する ここでは、 クラスタ を作成する方法として「eksctl」と「マネジメントコンソール」の2つの選択肢があります。 初心者がステップ バイス テップで進めたい場合は後者のマネジメントコンソールを使用することをおすすめしますが、今回は手間を省略するためにeksctlコマンドを使用して クラスタ を作成します。 以下のコマンドを使用して クラスタ を作成しますが、「 クラスタ 名」・「パブリックサブネットID1」・「パブリックサブネットID2」はそれぞれ実際の クラスタ 名とパブリックサブネットIDに置き換えてください # コマンドガイドライン ## https://eksctl.io/usage/vpc-configuration/#use-existing-vpc-other-custom-configuration eksctl create cluster --name 「クラスタ名」\ --region ap-northeast-1 \ --without-nodegroup \ --vpc-public-subnets=[パブリックサブネットID1],[パブリックサブネットID2] c. クラスタ のノードグループを作成する NodeGroupを AWS コンソールを介して作成する場合、まず一連のロールを設定する必要があります。手続きを簡略化するために、今回も引き続きeksctlコマンドを使用してNodeGroupを作成します。 以下のコマンドを使用してNodeGroupを作成します。「 クラスタ 名」「ノードグループ名」はそれぞれ希望する名前に置き換えてください。 ※次にOpen Matchをインストールしますが、インストールコマンドを簡略化するために、デフォルトの設定を使用します。このため、最低でも3つ以上のCPUが必要となりますので、今回はt3.xlargeタイプのサーバを選択します。 # コマンドガイドライン ## https://eksctl.io/usage/managing-nodegroups/ eksctl create nodegroup \ --cluster 「クラスタ名」 \ --region ap-northeast-1 \ --name 「ノードグループ名」 \ --node-type t3.xlarge \ --nodes 2 \ --nodes-min 2 \ --nodes-max 2 ここまでは、EKSの環境設定が完了しました。 次に、AgonesとOpen Matchをインストールします。 2.Agonesのインストール Agonesをインストールする方法については、基本的には Agonesの公式ドキュメンテーション に従います。 インストール方法としては、 yaml インストールとHelmインストールの2つの方法がありますが、以下の理由からHelmを使用してAgonesをインストールする方法を選択しました。 ① Helmを使用すると、 Kubernetes アプリケーションのデプロイを再現可能で一貫性のある形で行うことができます。これにより、運用上の一貫性と信頼性を確保する ② Helmはチャートと呼ばれるパッケージ形式を使用するため、チーム間での共有や再利用が容易になる ③ 複雑なアプリケーションを構成するためのパラメータ化された設定を提供します。これにより、カスタマイズや再構成が容易になる ④ Helmはデプロイのバージョン管理、つまりリリースの管理を容易にします。これにより、バージョン間の移行がスムーズに行う Agonesのインストールコマンドは以下のとおりです。 [my-release]はリリースの名前で、適宜置き換えてください。 # 公式のstable Helmリポジトリを追加 helm repo add agones https://agones.dev/chart/stable # Helmを使ってAgonesをインストール helm install [my-release] --namespace agones-system agones/agones 上記のコマンドを使用すると、公式のstable Helm リポジトリ を追加し、Helmを使ってAgonesをインストールできます。[my-release]の部分にはインストールするリリースの名前を指定します。 また、Agonesは agones-system という名前の空間にインストールされます。 3.Openmatchのインストール Openmatchの公式ドキュメンテーション に従ってOpenmatchをインストールします。 OpenMatchのインストールコマンドは以下のとおりです。 [my-release]はリリースの名前で、適宜置き換えてください。 # Open MatchのHelmリポジトリを追加 helm repo add open-match https://open-match.dev/chart # Helmを使ってOpen Matchをインストール helm install [my-release] open-match/open-match --namespace open-match --set open-match-core.enabled=true 上記のコマンドを使用すると、Open MatchのHelm リポジトリ を追加し、Helmを使ってOpen Matchをインストールできます。 また、Open Matchはopen-matchという 名前空間 にインストールされ、 open-match-core が有効になります。 4.動作確認 環境の構築が完了したので、AgonesとOpen Matchが正しくインストールされていることを確認するために、AgonesのデモとOpen Matchのデモを使用してテストを行います。 環境の構築が完了している場合は、以下の手順を実行してテストを行ってください。 Agonesデモのデプロイと動作確認 Agones公式ドキュメントの ゲームサーバ作成手順 に従ってサンプルゲームサーバーをデプロイします。これにより、Agonesが正常に動作するかを確認できます。 サンプルゲームサーバーをデプロイする kubectl create -f https://raw.githubusercontent.com/googleforgames/agones/release-1.32.0/examples/simple-game-server/gameserver.yaml GameServerの状態を確認する kubectl get gs 確認ポイントとして、GameServerのステータスが Ready になっていることを確認してください。 $ kubectl get gs NAME STATE ADDRESS PORT NODE AGE simple-game-server-h4h2w Ready ec2-18-182-6-144.ap-northeast-1.compute.amazonaws.com 7393 ip-192-168-0-130.ap-northeast-1.compute.internal 18s Open Matchデモのデプロイと動作確認 Openmatch 公式デモの作成手順 に従ってOpen Matchのデモをデプロイします。これにより、Open Matchが正常に動作するかを確認できます。 サンプルマッチメイキングフロントエンドとバックエンドをデプロイする kubectl create namespace open-match-demo kubectl apply --namespace open-match-demo \ -f https://open-match.dev/install/v1.7.0/yaml/02-open-match-demo.yaml マッチメイキングフロントエンドとバックエンドの状態を確認する kubectl get pods -n open-match-demo 上記の手順に従い、Open Matchのサンプルマッチメイキングでフロントエンドとバックエンドをデプロイし、 kubectl get pods -n open-match-demo コマンドでポッドの状態を確認します。 確認ポイントとして、 ① すべてのポッドのステータスが Running になっていることを確認する $ kubectl get pods -n open-match-demo NAME READY STATUS RESTARTS AGE om-demo-7bf887b848-75nvn 1/1 Running 0 35s om-function-58b954cf5f-4gqr4 1/1 Running 0 35s om-function-58b954cf5f-ll6pz 1/1 Running 0 35s om-function-58b954cf5f-nqrks 1/1 Running 0 35s ② PortForward を使用してデモページにアクセスできることを確認する $ kubectl port-forward --namespace open-match-demo service/om-demo 51507:51507 Forwarding from 127.0.0.1:51507 -> 51507 Forwarding from [::1]:51507 -> 51507 終わりに 以上で、EKSの環境構築およびAgones/Open Matchのインストールプロセスの説明が完了しました。また、公式デモを使用してテストを行いました。 次に、マッチメイキングサービスの構築とGameClientの実装について詳しく説明します。 Part2 と Part3 では、マッチメイキングサービスのカスタマイズ方法とGameClientの開発について取り上げます。 引き続き、お楽しみにしてください! 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 参考 https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/getting-started-console.html https://open-match.dev/site/docs/ https://agones.dev/site/docs/ 執筆: @chen.sun 、レビュー: @yamashita.yuki ( Shodo で執筆されました )
こんにちは、金融ソリューション事業部の孫です。 シリーズの最初の記事( Part1 )では、 Kubernetes の強力な機能を活用するためにEKS(Elastic Kubernetes Service)をどのように設定するかについて詳しく説明しました。 EKSの設定が成功した後、ゲームのインフラでよく使われるAgonesとOpen Matchをインストールしました。 また、公式デモでテストを行い、インストールが正しく行われたことを確認しました。 Kubernetes に基づくAgonesとOpen Matchという2つの コンポーネント について、理解していない方がいらっしゃるかもしれません。 そのため、Part2では、まずAgonesとOpen Matchの基本的な概念を簡単に紹介します。 次に、実践でマッチングシステムのデモを作成し、どのようにAgonesとOpen Matchを組み合わせて効率的で柔軟なDedicated Serverの管理とマッチングを実現するかを示します。 Agonesの紹介 OpenMatchの紹介 OpenMatchのマッチメイカーを作成する一般的なフロー OpenMatchとAgonesの統合 実践:ゲームマッチングシステムのデモ作成 マッチングルールの定義 事前準備 マッチング関数の作成 GameFrontend Director Match Profilesの作成 Agones Allocator ServiceによるOpenMatchの統合 AgonesのAllocate機能のパッケージ化 Allocator ServiceパッケージをOpenMatchに統合 MatchFunction デプロイと動作確認 ローカルにおいてDockerImageのコンパイル DockerImageをAmazon ECRにアップロード モジュールをEKSにデプロイ 動作確認 終わりに 参考 Agonesの紹介 Agonesは、 Google Cloudと Ubisoft が共同で開発されて、 Ubisoft 内の大規模な マルチプレイヤー オンラインゲーム(MMO)で利用されているソリューションです。 また、プログラミングが OSS で公開されている為安全に独自ネットワーク内で動作させることが可能です。 Agonesは、 Kubernetes の特性を活用してゲームサーバーを効率的に、そしてスケーラブルに運用および管理する方法を提供します。 Agonesの主要な コンポーネント には、GameServer、Fleetがあります。 Agonesでは、開発者は簡単な Kubernetes のコマンドを用いてGameServerを作成および管理することが可能で、これによりゲームサーバーの管理の複雑さが大幅に低減されます。 Agonesがゲームサーバーのライフサイクルを管理する中で、以下の6つのステージを定義しています。 Agonesはゲームサーバーのステージに応じて、適切な処理を実行します。 Scheduled (予定):GameServerがスケジュールされ、Nodeに割り当てられる Requested (要求): Kubernetes のPodが作成され、GameServerが作成される Starting (起動中):GameServerが起動し、プレイヤーがゲームに接続できる状態になる前の準備状態 Ready (準備完了):GameServerがアクティブ状態で、プレイヤーが接続できる Allocated (割り当て済み):プレイヤーがGameServerに接続し、リソースが確保されている Shutdown (シャットダウン):すべてのプレイヤーが切断され、GameServerがシャットダウンする OpenMatchの紹介 OpenMatchは、Frontend API 、Backend API 、Query API 、Functionなど、複数の コンポーネント から成り立っています。 これらの コンポーネント はそれぞれが独自の役割を果たしながら協調して働き、マッチングシステムを構築します。 OpenMatchのマッチングフローは以下のとおりです。 プレイヤーがFrontend API にマッチングリク エス トを送信する Frontend API はそのリク エス トを内部の状態でストアに保存する マッチング関数がQuery API を使用して状態ストアから条件に合うプレイヤーを問い合わせする マッチング関数がBackend API にマッチング結果を返す Backend API がプレイヤーにマッチング結果を返す OpenMatchのマッチメ イカ ーを作成する一般的なフロー OpenMatchのマッチメ イカ ーを作成するには主に三つのステップがあります。 マッチングルールを定義する マッチング関数を作成する マッチメ イカ ーの設定および運用を行う まず、マッチングルールを定義します。 このルールはマッチングロジックを反映したもので、プレイヤーのレベル、地域、スキルなどを含めます。 次に、マッチング関数を作成します。 この関数はQuery API を使用してマッチングルールに合致するプレイヤーを問い合わせ、そのマッチング結果をBackend API に返します。 最後に、マッチメ イカ ーの設定と運用を行います。OpenMatchは多くの設定オプションを提供しており、それらはニーズに応じて設定できます。 OpenMatchとAgonesの統合 OpenMatchとAgonesの統合は、効率的なゲームマッチングシステムを構築する上での重要な部分であり、主に二つのプロセスが関与しています。 OpenMatchのマッチング関数からAgonesのGameServerを呼びだすところ GameServerのライフサイクルを管理するところ OpenMatchはプレイヤーのマッチングを担当し、一方AgonesはGameServerのライフサイクルの管理を担当します。 これら二つの組み合わせにより、プレイヤーのニーズに応じてGameServerを動的に作成および割り当てることができます。 OpenMatchのマッチング関数内で、Agones SDK を通じて新しいGameServerを作成できます。 しかし、ほとんどの場合新しいGameServerを作成するだけではなく既存のGameServerをスケジュールし、割り当てることがより重要です。 GameServerのパフォーマンス、負荷、地理的な位置などを評価し、最適なGameServerを見つける必要があります。 適切なGameServerを見つけたら、そのアドレスをプレイヤーに返します、プレイヤーはそのアドレスを使用してGame Serverに接続しゲーム体験を始めます。 ここで終わりではありませんが、GameServerの状態を監視し、必要に応じて調整する必要があります。 例えば、GameServerの負荷が高すぎる場合、新しいGameServerを作成して負荷を分散できます。 一方、GameServerのプレイヤー数が減少した場合、それをシャットダウンしてリソースを節約することも可能です。 実践:ゲームマッチングシステムのデモ作成 先に紹介したOpenMatch マッチメーカー の作成プロセスに従って、デモを作成し始めます。 マッチングルールの定義 このデモでは、ユーザーのスキルレベルとレイテンシを基にスコアを算出し、同一リージョン内でスコアが近いユーザーをマッチングするというルールを実装します。 それぞれのマッチングルームは4人のプレイヤーで構成され、スコアが近いユーザー同士は一緒になります。 以下では、このマッチングの詳細や手順、そして適用する アルゴリズム について具体的に説明します。 チケット詳細 Ticket Details チケットは以下図のGameFrontendによって作成され、OpenMatchのFrontendにプッシュされる情報です。 チケットにはプレイヤーに関する情報が含まれており、マッチングの際に使用されます。 以下「GameFrontend」、「Director」、「MatchFunction」章の実装で利用されます。 今回のデモでは、チケット詳細には以下の要素が含まれています。 タグ tag :タグを使ってチケットを分類することが可能で、それによりマッチングシステムはより効率的に対応するキューを見つけることができる 今回はゲームモード Game Mode という設計を前提に実装するため、タグはmode.sessionとする リージョン Region :これはプレイヤーがいる地理的な地域を示しているが、今回はap-northeast-1、ap-northeast-3とする スキルレベル Skill Level :これはプレイヤーのスキルレベルを示しており、0.0から2.0の範囲で設定される レイテンシ Latency :これはプレイヤーのネットワーク遅延を示している ※ほとんどの人は0に近いですが、ネットワークの信頼性をシミュレートするために、一部の人は無限大に設定されている マッチング機能の基準 MatchFunction Criteria 今回のデモでは、マッチングの基準を以下に定義します。 以下「Director」、「MatchFunction」章の実装で利用されます。 まずはプレイヤーの地理的な地域とゲームモードを基準に、チケットプールを作成する 次に、各プレイヤーに対して score = skill - (latency / 1000.0) の アルゴリズム を使用してスコアを算出する そして、スコアに基づいてルームにプレイヤーを配置する 高スキル、低レイテンシのユーザーは同じルームに割り当てられる 1つのマッチに参加できるプレイヤーの上限は、4人と定められている ディレクタープロファイル Director Profiles ディレクタープロファイルはディレクターが生成するオブジェクトで、マッチのリク エス トに使用されます。 以下「Director」章の実装で利用されます。 今回のデモでは、ディレクターは5秒ごとにプロファイルを生成し、マッチをリク エス トする。 また、ディレクターはプレイヤーの地理的な地域に応じて、対応する地理的な地域のGameServerをプレイヤーに割り当てる 事前準備 OpenMatchの リポジトリ をローカル環境にクローンする ベースとなるコードは、tutorials/matchmaker101のパスに存在する git clone https://github.com/googleforgames/open-match.git Golang による実装のため、適切な IDE を設定する この記事では、 Visual Studio Code にGo plugin(v.39.0)をインストールした環境で開発を進めた Docker環境を準備する 対象の環境でDockerをセットアップするには、 Dockerのインストール のドキュメントを参照してください マッチング関数の作成 Openmatch公式ドキュメントの Tutorial をベースに、ステップ バイス テップでGameFrontend、Director、MatchFunctionといった コンポーネント を設計・作成します。 ※TutorialはOpenmatchの フレームワーク プログラムで、マッチングロジックは上記のルールに従って独自に実装する必要があります。 以下の図は、Openmatch の全体的な アーキテクチャ で、赤く囲まれた部分は独自に実装すべき部分です。 GameFrontend チケット生成関数を実装する ※ベースコード: https://github.com/googleforgames/open-match/blob/main/tutorials/matchmaker101/frontend/ticket.go 「マッチングルールの定義」章で定義したチケット詳細 Ticket Details のルールに従って、 mode.session という名前のタグを定義して、次にランダムにスキルとレイテンシの値を設定します。 リージョンの設定については、具体的なユーザーがどこから接続するかは確定していないため、外部から値を取得するように定義します。この情報はクライアントから取得する必要があります。 # ticket.go import( "open-match.dev/open-match/pkg/pb" // 必要なパッケージ追加 "math/rand" "time" ) func makeTicket(region string) *pb.Ticket { modes := []string{"mode.session"} ticket := &pb.Ticket{ SearchFields: &pb.SearchFields{ Tags: modes, DoubleArgs: CreateDoubleArgs(), StringArgs: map[string]string{ "region": region, }, }, } return ticket } func CreateDoubleArgs() map[string]float64 { rand.Seed(time.Now().UTC().UnixNano()) skill := 2 * rand.Float64() latency := 50.0 * rand.ExpFloat64() return map[string]float64{ "skill": skill, "latency": latency, } } echo フレームワーク を使用してFrontendのAPIServerを実装する ※ベースコード: https://github.com/googleforgames/open-match/blob/main/tutorials/matchmaker101/frontend/main.go この API ServerのURLは: GET /play/:region で、regionパラメータを持っています。 # frontend/main.go import( "github.com/labstack/echo" ) type matchResponce struct { IP string `json:"ip"` Port string `json:"port"` Skill string `json:"skill"` Latency string `json:"latency"` Region string `json:"region"` } var fe pb.FrontendServiceClient var matchRes = &matchResponce{} func main() { //:(ベースコートを省略する) // fe = pb.NewFrontendServiceClient(conn)以降のコードをコメントアウト e := echo.New() e.GET("/play/:region", handleGetMatch) e.Start(":80") } func handleGetMatch(c echo.Context) error { // Create Ticket. region := c.Param("region") req := &pb.CreateTicketRequest{ Ticket: makeTicket(region), } matchRes.Skill = fmt.Sprintf("%f", req.Ticket.SearchFields.DoubleArgs["skill"]) matchRes.Latency = fmt.Sprintf("%f", req.Ticket.SearchFields.DoubleArgs["latency"]) matchRes.Region = req.Ticket.SearchFields.StringArgs["region"] resp, err := fe.CreateTicket(context.Background(), req) if err != nil { log.Fatalf("Failed to CreateTicket, got %v", err) return c.JSON(http.StatusInternalServerError, matchRes) } // Polling TicketAssignment. deleteOnAssign(fe, resp) return c.JSON(http.StatusOK, matchRes) } func deleteOnAssign(fe pb.FrontendServiceClient, t *pb.Ticket) { //:(ベースコートを省略する) if got.GetAssignment() != nil { log.Printf("Ticket %v got assignment %v", got.GetId(), got.GetAssignment()) conn := got.GetAssignment().Connection slice := strings.Split(conn, ":") matchRes.IP = slice[0] matchRes.Port = slice[1] break } } Director Match Profilesの作成 ※ベースコード: https://github.com/googleforgames/open-match/blob/main/tutorials/matchmaker101/director/profile.go 「マッチングルールの定義」章で定義した マッチング機能の基準 MatchFunction Criteria のチケットプール作成ルールに従って、 TagPresentFilters および StringEqualsFilter を定義します。 「マッチングルールの定義」章で定義したディレクタープロファイル Director Profiles のゲームサーバー選択ルールに従って、 profile.Extensions を定義します。 # profile.go type AllocatorFilterExtension struct { Labels map[string]string `json:"labels"` Fields map[string]string `json:"fields"` } func generateProfiles() []*pb.MatchProfile { var profiles []*pb.MatchProfile regions := []string{"ap-northeast-1", "ap-northeast-3"} for _, region := range regions { profile := &pb.MatchProfile{ Name: fmt.Sprintf("profile_%s", region), Pools: []*pb.Pool{ { Name: "pool_mode_" + region, TagPresentFilters: []*pb.TagPresentFilter{ {Tag: "mode.session"}, }, StringEqualsFilters: []*pb.StringEqualsFilter{ {StringArg: "region", Value: region}, }, }, }, } // build filter extensions filter := AllocatorFilterExtension{ Labels: map[string]string{ "region": region, }, Fields: map[string]string{ "status.state": "Ready", }, } // to protobuf Struct labelsStruct := &structpb.Struct{Fields: make(map[string]*structpb.Value)} for key, value := range filter.Labels { labelsStruct.Fields[key] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: value}} } fieldsStruct := &structpb.Struct{Fields: make(map[string]*structpb.Value)} for key, value := range filter.Fields { fieldsStruct.Fields[key] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: value}} } // put data to the protobuf Struct filterStruct := &structpb.Struct{Fields: map[string]*structpb.Value{ "labels": {Kind: &structpb.Value_StructValue{StructValue: labelsStruct}}, "fields": {Kind: &structpb.Value_StructValue{StructValue: fieldsStruct}}, }} // to google.protobuf.Any object filterAny, err := ptypes.MarshalAny(filterStruct) if err != nil { panic(err) } profile.Extensions = map[string]*any.Any{ "allocator_filter": filterAny, } profiles = append(profiles, profile) } return profiles } Agones Allocator ServiceによるOpenMatchの統合 Agonesを統合し、マッチング結果に対応するGameServerアドレスを割り当てます。 プレイヤーはこのアドレスを通じて対応するGameServerに接続します。 Agones Allocate機能の実装は独自のものであり、OpenMatchの チュートリアル には基礎となるコードが存在しません。 そのため、directorフォルダの下に新規ファイルとしてallocator_director.goを作成します。 AgonesのAllocate機能のパッケージ化 Agonesが提供する Allocator Service を使って対応するGameServerを取得します。 デフォルトのクライアント証明書を取得する Allocator Service はmTLS認証モードを使用しており、これにより証明書を使用してサービスに接続することは必須になります。 証明書は既にhelmインストール時に作成されています。 独自の証明書を使用することも可能で、具体的な設定は こちら を参照してください。 下記のコマンドを実行してデフォルトのクライアント証明書を取得する ※筆者は Mac の環境でコマンドを実行しています。 Linux の環境であれば、 base64 -D の代わりに base64 -d コマンドを使用してください。 # MACのコマンド kubectl get secret allocator-client.default -n default -ojsonpath="{.data.tls\.crt}" | base64 -D > "client.crt" kubectl get secret allocator-client.default -n default -ojsonpath="{.data.tls\.key}" | base64 -D > "client.key" kubectl get secret allocator-tls-ca -n agones-system -ojsonpath="{.data.tls-ca\.crt}" | base64 -D > "tls-ca.crt" 取得したクライアント証明書を配置します。 上記でダウンロードした証明書を特定のパスに保存し、Pathとして定義します。 ここでは、 allocator/certfile ディレクト リに保存することを想定しています。 # agones_allocator.go const ( KeyFilePath = "allocator/certfile/client.key" CertFilePath = "allocator/certfile/client.crt" CaCertFilePath = "allocator/certfile/tls-ca.crt" ) Allocator Serviceのクライアントを作成する 外部からAgonesのAllocate機能を呼びだす必要がある場合、 NewAgonesAllocatorClient を呼びだすとクライアントが生成されます。 ※ご注意: コードが長くなりすぎないように、エラー処理に関連するコードは削除しています。 # agones_allocator.go type AgonesAllocatorClientConfig struct { KeyFile string CertFile string CaCertFile string AllocatorServiceHost string AllocatorServicePort int Namespace string MultiCluster bool } type AgonesAllocatorClient struct { Config *AgonesAllocatorClientConfig DialOpts grpc.DialOption } func NewAgonesAllocatorClient() (*AgonesAllocatorClient, error) { config := &AgonesAllocatorClientConfig{ KeyFile: KeyFilePath, CertFile: CertFilePath, CaCertFile: CaCertFilePath, AllocatorServiceHost: AllocatorServiceHost, AllocatorServicePort: AllocatorServicePort, Namespace: "default", MultiCluster: false, } cert, err = ioutil.ReadFile(config.CertFile) key, err = ioutil.ReadFile(config.KeyFile) ca, err = ioutil.ReadFile(config.CaCertFile) dialOpts, err := createRemoteClusterDialOption(cert, key, ca) return &AgonesAllocatorClient{ Config: config, DialOpts: dialOpts, }, nil } func createRemoteClusterDialOption(clientCert, clientKey, caCert []byte) (grpc.DialOption, error) { cert, err := tls.X509KeyPair(clientCert, clientKey) tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true} if len(caCert) != 0 { tlsConfig.RootCAs = x509.NewCertPool() if !tlsConfig.RootCAs.AppendCertsFromPEM(caCert) { return nil, errors.New("only PEM format is accepted for server CA") } } return grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), nil } Allocateのメイン関数を実装する この関数では、まずgrpc( grpc.Dial ) プロトコル を使用して Allocator Service に接続します。 次に、GameServerの選択ルール( assignmentGroup.Assignment.Extensions )を取得します。 このルールに基づいて対応するGameServerを取得し、最終的に各Assignmentの address フィールドにアドレスを付与します。 ※ご注意: コードが長くなりすぎないように、エラー処理に関連するコードは削除しています。 # agones_allocator.go func (c *AgonesAllocatorClient) Allocate(req *pb.AssignTicketsRequest) error { conn, err := grpc.Dial(fmt.Sprintf("%s:%d", c.Config.AllocatorServiceHost, c.Config.AllocatorServicePort), c.DialOpts) defer conn.Close() grpcClient := pb_agones.NewAllocationServiceClient(conn) for _, assignmentGroup := range req.Assignments { filterAny := assignmentGroup.Assignment.Extensions["allocator_filter"] filter := &structpb.Struct{} if err := ptypes.UnmarshalAny(filterAny, filter); err != nil { panic(err) } request := &pb_agones.AllocationRequest{ Namespace: c.Config.Namespace, GameServerSelectors: []*pb_agones.GameServerSelector{ { MatchLabels: filter.Fields["labels"], }, }, MultiClusterSetting: &pb_agones.MultiClusterSetting{ Enabled: c.Config.MultiCluster, }, } resp, err := grpcClient.Allocate(context.Background(), request) if len(resp.GetPorts()) > 0 { address := fmt.Sprintf("%s:%d", resp.Address, resp.Ports[0].Port) assignmentGroup.Assignment.Connection = address } } return nil } Allocator ServiceパッケージをOpenMatchに統合 上記のOpenMatchの アーキテクチャ 図に基づき、Agonesサービスを呼び出してGameServerを取得する コンポーネント はDirectorです。 そのため、呼び出しコードをDirectorに統合する必要があります。 ※ベースコード: https://github.com/suecideTech/try-openmatch-agones/blob/master/OpenMatch/mod_matchmaker101/director/main.go GameServerのassgin関数を修正します。 ここでは、上記で作成した Allocator Service パッケージを使用して実際のGameServerアドレスを取得します。 元のコードでは GameServerAllocations を使ってGameServerアドレスを取得していますが、これはAgonesが推奨している方法ではなく、また後期の拡張にも適していません。 そのため、公式に推奨されている Allocator Service をラップしてGameServerを取得するようにしました。 # director/main.go func assign(be pb.BackendServiceClient, matches []*pb.Match) error { for _, match := range matches { ticketIDs := []string{} for _, t := range match.GetTickets() { ticketIDs = append(ticketIDs, t.Id) } aloReq := &pb.AssignTicketsRequest{ Assignments: []*pb.AssignmentGroup{ { TicketIds: ticketIDs, Assignment: &pb.Assignment{ Extensions: match.Extensions, }, }, }, } client, err := allocator.NewAgonesAllocatorClient() client.Allocate(aloReq) if _, err := be.AssignTickets(context.Background(), aloReq); err != nil { return fmt.Errorf("AssignTickets failed for match %v, got %w", match.GetMatchId(), err) } log.Printf("Assigned server %v to match %v", conn, match.GetMatchId()) } return nil } MatchFunction ユーザーマッチングルールを実装します。 ※ベースコード: https://github.com/suecideTech/try-openmatch-agones/blob/master/OpenMatch/mod_matchmaker101/matchfunction/mmf/matchfunction.go 「マッチングルールの定義」章で定義した マッチング機能の基準 MatchFunction Criteria のユーザーマッチングルールに従って、まずユーザーのスコアを計算し、スコアの大きさに基づいて4人部屋を割り当てます。 # matchfunction.go const ( matchName = "basic-matchfunction" ticketsPerPoolPerMatch = 4 ) func (s *MatchFunctionService) Run(req *pb.RunRequest, stream pb.MatchFunction_RunServer) error { //:(ベースコートを省略する) //poolTickets, err := matchfunction.QueryPools(stream.Context(), s.queryServiceClient, req.GetProfile().GetPools()) p := req.GetProfile() tickets, err := matchfunction.QueryPool(stream.Context(), s.queryServiceClient, p.GetPools()[0]) //:(ベースコートを省略する) idPrefix := fmt.Sprintf("profile-%v-time-%v", p.GetName(), time.Now().Format("2006-01-02T15:04:05.00")) proposals, err := makeMatches(req.GetProfile(), idPrefix, tickets) //:(ベースコートを省略する) } func (s *MatchFunctionService) makeMatches(ticketsPerPoolPerMatch int, profile *pb.MatchProfile, idPrefix string, tickets []*pb.Ticket) ([]*pb.Match, error) { if len(tickets) < ticketsPerPoolPerMatch { return nil, nil } ticketScores := make(map[string]float64) for _, ticket := range tickets { ticketScores[ticket.Id] = score(ticket.SearchFields.DoubleArgs["skill"], ticket.SearchFields.DoubleArgs["latency"]) } sort.Slice(tickets, func(i, j int) bool { return ticketScores[tickets[i].Id] > ticketScores[tickets[j].Id] }) var matches []*pb.Match count := 0 for len(tickets) >= ticketsPerPoolPerMatch { matchTickets := tickets[:ticketsPerPoolPerMatch] tickets = tickets[ticketsPerPoolPerMatch:] var matchScore float64 for _, ticket := range matchTickets { matchScore += ticketScores[ticket.Id] } eval, err := anypb.New(&pb.DefaultEvaluationCriteria{Score: matchScore}) if err != nil { log.Printf("Failed to marshal DefaultEvaluationCriteria into anypb: %v", err) return nil, fmt.Errorf("Failed to marshal DefaultEvaluationCriteria into anypb: %w", err) } newExtensions := map[string]*anypb.Any{"evaluation_input": eval} newExtensions for k, v := range origExtensions { newExtensions[k] = v } matches = append(matches, &pb.Match{ MatchId: fmt.Sprintf("%s-%d", idPrefix, count), MatchProfile: profile.GetName(), MatchFunction: matchName, Tickets: matchTickets, Extensions: newExtensions, }) count++ } return matches, nil } func score(skill, latency float64) float64 { return skill - (latency / 1000.0) } ここまでで、マッチング機能とGameServerのケジューリング機能の実装が完了しました。 次に、作成したモジュールをそれぞれEKSにアップロードし、デモとしてテストします。 具体的に完成したデモの アーキテクチャ は、以下の図の通りです。 デプロイと動作確認 ローカルにおいてDockerImageの コンパイル GameFrontend # OpenMatch/mod_matchmaker101/frontend/Dockerfile docker build -t localimage/mod_frontend:0.1 . Director # OpenMatch/mod_matchmaker101/director/Dockerfile docker build -t localimage/mod_director:0.1 . MatchFunction # OpenMatch/mod_matchmaker101/matchfunction/Dockerfile docker build -t localimage/mod_matchfunction:0.1 . DockerImageを Amazon ECRにアップロード EKSでDockerImageを取得する際、ローカルのイメージにアクセスできないため、イメージを Amazon ECRサービスにアップロードする必要があります。 ※ Amazon ECRはイメージを保管するための専用レポジトリで、Docker Hubなどと同様のサービスがあります。 Amazon ECRでプライベートイメージ リポジトリ を作成する具体的な方法については、 ECRでプライベートリポジトリを作成する を参照してください。 以下の名称の Amazon ECRプライベートイメージ リポジトリ をそれぞれ作成する Frontendの リポジトリ 名:mod_frontend Directorの リポジトリ 名:mod_director MatchFunctionの リポジトリ 名:mod_matchfunction ローカルのイメージを Amazon ECRにアップロードする # Frontend docker tag localimage/mod_frontend:0.1 {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_frontend:0.1 docker push {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_frontend:0.1 # Director docker tag localimage/mod_director:0.1 {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_director:0.1 docker push {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_director:0.1 # MatchFunction: docker tag localimage/mod_matchfunction:0.1 {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_matchfunction:0.1 docker push {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_matchfunction:0.1 モジュールをEKSにデプロイ デプロイ yaml ファイル内のimageアドレスを、上記で作成した Amazon ECRのアドレスに変更します。 # Frontend: ## yaml file path ## OpenMatch/mod_matchmaker101/frontend/frontend.yaml image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_frontend:0.1 # Director ## yaml file path ## OpenMatch/mod_matchmaker101/director/director.yaml image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_director:0.1 # MatchFunction: ## yaml file path ## OpenMatch/mod_matchmaker101/matchfunction/matchfunction.yaml image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_matchfunction:0.1 各 Yaml ファイルを以下のように修正します。 # Frontend.yaml ## yaml file path ## OpenMatch/mod_matchmaker101/frontend/frontend.yaml ## KindをDeploymentに変更し、HTTP LBサービスを追加します apiVersion: v1 kind: Service metadata: name: frontend-endpoint annotations: service.alpha.kubernetes.io/app-protocols: '{"http":"HTTP"}' labels: app: frontend spec: type: NodePort selector: app: frontend ports: - port: 80 protocol: TCP name: http targetPort: frontend --- apiVersion: apps/v1 kind: Deployment metadata: name: frontend namespace: default labels: app: frontend spec: replicas: 1 selector: matchLabels: app: frontend template: metadata: labels: app: frontend spec: containers: - name: frontend image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_frontend:0.1 imagePullPolicy: Always ports: - name: frontend containerPort: 80 # Director.yaml ## yaml file path ## OpenMatch/mod_matchmaker101/director/director.yaml apiVersion: v1 kind: Pod metadata: name: director namespace: openmatch-poc spec: containers: - name: director image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_director:0.1 imagePullPolicy: Always hostname: director # MatchFunction.yaml ## yaml file path ## OpenMatch/mod_matchmaker101/matchfunction/matchfunction.yaml apiVersion: v1 kind: Pod metadata: name: matchfunction namespace: openmatch-poc labels: app: openmatch component: matchfunction spec: containers: - name: matchfunction image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_MatchFunction:0.1 imagePullPolicy: Always ports: - name: grpc containerPort: 50502 --- kind: Service apiVersion: v1 metadata: name: matchfunction namespace: openmatch-poc labels: app: openmatch component: matchfunction spec: selector: app: openmatch component: matchfunction clusterIP: None type: ClusterIP ports: - name: grpc protocol: TCP port: 50502 それぞれの yaml ファイルをEKSに適用し、マッチングシステムをデプロイします。 # GameFrontend kubectl apply -f frontend.yaml # Director kubectl apply -f director.yaml # MatchFunction kubectl apply -f matchfunction.yaml 動作確認 以下の図に示すように、 frontend.yaml で作成されたServiceに接続し、マッチングシステムをテストします。 この frontend-endpoint サービスにローカルでもアクセスできるようにするため、 Kubernetes のPortForwadering機能を使用します。 Frontendサービスをローカルに マッピング する 下図の⑤エリアです。 # Frontend サービスをローカルの8081ポートにマッピングします kubectl port-forward services/frontend-ednpoint 8081:80 8つの新しいターミナルを作成して、8名のプレイヤーがFrontendサービスに接続するのをシミュレートする 下図の①エリアで4名(ap-northeast-1)+4名(ap-northeast-3)のプレイヤーが接続するのをシミュレートします。 # Get: /Frontend/play/regionname ## 4名(ap-northeast-1) curl 127.0.0.1:8081/play/ap-northeast-1 ## 4名(ap-northeast-3) curl 127.0.0.1:8081/play/ap-northeast-3 エラーの有無を確認するために、matchfunction/director/frontendモジュールのログ情報を出力する 下図の②〜④エリアです。 # matchfuntion log kubectl logs --tail 4 matchfunction -n openmatch-poc # director log kubectl logs --tail 4 director -n openmatch-poc # frontend log kubectl logs --tail 4 deployments/frontend 上記の手順に従って、8名のプレイヤーがマッチングを開始するシミュレーションを行います。 確認ポイントは次の通りです。 ②〜④エリアのログにはエラー出力がありません。 ①の8名のクライアント全員がIP、Port情報を正常に取得します。 また、前の4名のプレイヤーは同じグループにマッチされるため、その IP:Port アドレスは同じです。 後の4名のプレイヤーも同じグループにマッチされ、その IP:Port アドレスも同じです。 ⑥エリアでは、GameServerのステータスを確認し、2台のサーバーがAllocated状態にあることを確認します。 終わりに これまでに、AgonesとOpen Matchを使用して高可用性と拡張性、スケジューリングが可能なマッチングシステムを構築しました。 次に、 Part3 ではUnrealEngineを使用してGameClientを開発し、このマッチングシステムに接続する方法を説明します。 これにより、マッチング機能を持ち、Agonesを使用してDedicated Serverをスケジューリングする マルチプレイ ゲームの開発を完了します。 引き続き、お楽しみにしてください! 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 参考 https://agones.dev/site/docs/overview/ https://open-match.dev/site/docs/ 執筆: @chen.sun 、レビュー: @yamashita.yuki ( Shodo で執筆されました )
こんにちは、金融ソリューション事業部の孫です。 前回の Part1 記事に続きまして Part2 では、OpenMatchとAgonesを使用して、柔軟性がありスケーラブルなゲームマッチングとゲームサーバー管理システムの構築方法を詳しく説明しました。 この記事(Part3)では、UnrealEngineを利用して、オンラインマルチプレーヤーゲームのデモを完成させます。 さらに、 Part2 で開発したマッチメイキングサービスをUEクライアント(UnrealEngine GameClient)に統合します。 全体 アーキテクチャ は以下の図の通りです。 UEクライアントの実装について、前の 記事 で作成したデモを基にさらに拡張します。 プレーヤーマッチング機能とサーバー管理機能を追加することで、この度のクライアントの実現を目指します。 実施手順 1.前記事のデモにマッチング機能の追加 マッチング機能のボタンの追加 Frontend APIの呼びだす機能実装 2. Agones SDKを呼び出してGameServerの終了機能の実装 ユーザー数の管理機能の追加 Agones SDKを利用してGameServerの終了実装 3.パッケージ化したUEサーバーでAgones Fleetの作成 4.動作確認 終わりに 参考 実施手順 以下の順序でシステムを構築します: 前記事 のデモにマッチング機能の追加 Agones SDK を呼び出してGameServerの終了機能の実装 パッケージ化したUEサーバーでAgones Fleetの作成 動作確認 1. 前記事 のデモにマッチング機能の追加 この前の記事 UE5ネットワーク同期のC++実装例 では、プレーヤーが「Play」ボタンをクリックすると、UEゲームサーバーに接続してゲーム体験を開始する機能をすでに完成させています。 次に、マッチング機能のボタンを追加し、このボタンをクリックするとOpenMatchの Frontend API を呼び出してマッチング要求をリク エス トします。 マッチング機能のボタンの追加 以下の図のように、テキストエリア(Region入力用)とボタン(「Match」)をUnrealEngineのBluePrint Widget UIに配置します。 Frontend API の呼びだす機能実装 以下のモジュールをxxx.Build.csファイルに追加する 今回では、 Agones / HTTP / Json / JsonUtilities の4つのモジュールを使用します。 各モジュールは次のような役割を果たします。 Agones モジュールは Agones の SDK を使うために使用する HTTP モジュールは Frontend API の http リク エス トを送信するために使用する Json 、 JsonUtilities モジュールは http からの返却 Json データを解析するために使用する //xxx.Build.cs using UnrealBuildTool; //:(省略) PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", "EnhancedInput", "Agones", "HTTP", "Json", "JsonUtilities" }); //:(省略) LoginHUDWidget.h を編集する まずは、配置したWidgetsをバインドすることから始めます。 ※注意:BluePrint内の Widget 名は LoginHUDWidget.h ファイル内の変数名と同じでなければならず、 meta = (BindWidget) タグを追加して Widget をバインドします それを完了したら、httpモジュールをincludeした上でコールバック関数を作成します。 //LoginHUDWidget.h // "Http.h"ヘッダーファイルの追加 #include "Http.h" UCLASS() class TEST_DESERVER_API ULoginHUDWidget : public UUserWidget { GENERATED_BODY() public: //:(省略) UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UEditableText* regionName; UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UTextBlock* matchLabel; UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UButton* matchBtn; private: FHttpModule* Http; void OnFetchGameServerResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful); } LoginHUDWidget.cpp にマッチングボタンのクリックロジックを実装する //LoginHUDWidget.cpp // "Json.h", "JsonUtilities.h"ヘッダーファイルの追加 #include "Json.h" #include "JsonUtilities.h" // 構造関数内で関連コントロールとHttpモジュールを初期化します // MatchボタンにOnMatchmakingButtonClickedトリガー関数をバインドします void ULoginHUDWidget::NativePreConstruct() { Super::NativePreConstruct(); //:(省略) matchLabel->SetText(FText::FromString("Match")); FScriptDelegate MatchmakingDelegate; MatchmakingDelegate.BindUFunction(this, "OnMatchmakingButtonClicked"); matchBtn->OnClicked.Add(MatchmakingDelegate); //:(省略) Http = &FHttpModule::Get(); } // OnMatchmakingButtonClickedトリガー関数の処理ロジックを追加します void ULoginHUDWidget::OnMatchmakingButtonClicked() { statusLabel->SetText(FText::FromString("Start Matching!! Please wait")); TSharedRef<IHttpRequest, ESPMode::ThreadSafe> FetchGameServerHttpRequest = Http->CreateRequest(); FString frontendUrl = "127.0.0.1:8081/play/" + FString(regionName->GetText().ToString()); FetchGameServerHttpRequest->SetVerb("GET"); FetchGameServerHttpRequest->SetURL(frontendUrl); FetchGameServerHttpRequest->SetHeader("Content-Type", "application/json"); FetchGameServerHttpRequest->OnProcessRequestComplete().BindUObject(this, &ULoginHUDWidget::OnFetchGameServerResponse); FetchGameServerHttpRequest->ProcessRequest(); }; // Httpのコールバック関数の処理ロジックを追加します void ULoginHUDWidget::OnFetchGameServerResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful) { if (bWasSuccessful) { TSharedPtr<FJsonObject> JsonObject; TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString()); if (FJsonSerializer::Deserialize(Reader, JsonObject)) { FString IpAddress = JsonObject->GetStringField("ip"); FString Port = JsonObject->GetStringField("port"); FString LevelName = IpAddress + ":" + Port; statusLabel->SetText(FText::FromString(LevelName)); playBtn->SetVisibility(ESlateVisibility::Visible); } } } // OnPlayGameButtonClickedトリガー関数の処理ロジックを変更します // OpenMatchから返されたGameServerアドレスを接続ターゲットとして設定します void ULoginHUDWidget::OnPlayGameButtonClicked() { //:(省略) FString LevelName = statusLabel->GetText().ToString(); //:(省略) } 2. Agones SDK を呼び出してGameServerの終了機能の実装 すべてのプレイヤーがオフラインになった場合、AgonesSDKを呼び出して利用済のGameServerを削除した後、新しいGameServerを再作成します。 ユーザー数の管理機能の追加 UnrealEngine Gamemodeクラスにおいて PlayerNum 変数を追加して現在のプレーヤー数を保存します。 また、少なくとも1人のプレーヤーがこのGameServerに接続したことがあることを判断するため、 HasPlayerConnected のBool変数を追加します。 前の 記事 で、UnrealEngineによく使われるサーバー側の関数について既にいくつか紹介しました。 今回はその中の PostLogin 、 Logout 関数を用いることで、プレーヤー数の簡易的な統計データが取得します。 //xxxGameMode.h public: //:(省略) virtual void PostLogin(APlayerController* NewPlayer) override virtual void Logout(AController* Exiting) override private: //:(省略) int32 PlayerNum; bool HasPlayerConnected; //xxxGameMode.cpp // 構造関数で変数を初期化します xxxGameMode::xxxGameMode(){ //:(省略) int32 PlayerNum = 0; bool HasPlayerConnected = false; } void xxxGameMode::PostLogin(APlayerController* NewPlayer) { Super::PostLogin(NewPlayer); PlayerNum++; HasPlayerConnected = true; if (!HasPlayerConnected) { HasPlayerConnected = true; GetWorldTimerManager().SetTimer(CountDownPlayerNumHandle, this, &xxxGameMode::TickCount, 1.0f, true); } } void xxxGameMode::Logout(AController* Exiting) { Super::Logout(Exiting); PlayerNum--; } Agones SDK を利用してGameServerの終了実装 UnrealEngine Gamemodeクラスにおいて、Agones SDK のShutdown()関数を呼び出してGameServerがシャットダウンされます。 ※Agonesは一 定量 のGameServerを維持し、GameServerが閉じられると新たなGameServerの生成がトリガーされます。 //xxxGameMode.h public: //:(省略) virtual void Tick(float DeltaTime) override private: //:(省略) //Agones SDKの処理結果に対応するレスポンス関数 //成功処理後のレスポンス関数 void HandleShutdownSuccess(const FEmptyResponse& Response); //成功失敗時のレスポンス関数 void HandleShutdownError(const FAgonesError& Error); void TickCount(); //xxxGameMode.cpp void Atest_DEServerGameMode::HandleShutdownSuccess(const FEmptyResponse& Response) {   //デモのため、実際の処理を行いません UE_LOG(LogTemp, Log, TEXT("Game server successfully shutdown")); } void Atest_DEServerGameMode::HandleShutdownError(const FAgonesError& Error) { //デモのため、実際の処理を行いません UE_LOG(LogTemp, Error, TEXT("shutting down failed: %s"), *Error.ErrorMessage); } void xxxGameMode::TickCount() { Super::Tick(DeltaTime); if (HasPlayerConnected && PlayerNum <= 0) { FShutdownDelegate SuccessDelegate; SuccessDelegate.BindUFunction(this, FName("HandleShutdownSuccess")); FAgonesErrorDelegate ErrorDelegate; ErrorDelegate.BindUFunction(this, FName("HandleShutdownError")); GetWorldTimerManager().ClearTimer(CountDownPlayerNumHandle); AgonesSDK->Shutdown(SuccessDelegate, ErrorDelegate); } } 3.パッケージ化したUEサーバーでAgones Fleetの作成 UnrealEngine Editorを使用して、 Linux プラットフォーム向けのDedicated Serverをパッケージ化します。 以下の図のように、ターゲットプラットフォーム Linux -> Development -> Linux(server) を選択し、パッケージします。 ※ Windows OSではもしかするとターゲットプラットフォームの選択肢に Linux が存在しないかもしれません。 これは、 Linux プラットフォームのサポートが設定されていないためです。 具体的な設定方法については、 こちら を参照してください。 パッケージ化が完了したら、 Part2 で各モジュールをデプロイしたのと同じ手順で、EKSにGameServerをデプロイします。 ローカルにおいてDockerImageを コンパイル する 下記のDockerfileをパッケージ化の際に指定された保存先のルート ディレクト リに配置し、Dockerイメージ作成コマンドを実行します。 ## DockerFile ## 「AgonesOMServer」を実際のプロジェクト名に置き換えます FROM ubuntu:20.04 RUN apt-get update && apt-get install -y \ libxcursor1 \ libxrandr2 \ libxinerama1 \ libxi6 \ libgl1-mesa-glx \ && rm -rf /var/lib/apt/lists/* RUN addgroup --gid 1000 gameserver && \ adduser --gid 1000 --uid 1000 --shell /usr/sbin/nologin --home /home/gameserver --gecos "" --disabled-login --disabled-password gameserver COPY --chown=gameserver:gameserver ./LinuxServer /home/gameserver/LinuxServer RUN chmod -R 770 /home/gameserver/LinuxServer WORKDIR /home/gameserver/LinuxServer USER gameserver EXPOSE 7777/udp ENTRYPOINT ["/home/gameserver/LinuxServer/「AgonesOMServer」.sh"] ## Docker image build command $ docker build -t localimage/ue_server:0.1 . DockerImageを Amazon ECRにアップロードする Part2 と同様に、ue_serverという名前の Amazon ECRプライベー トリポジ トリを新規作成します。 次に、Dockerイメージを Amazon ECRにアップロードします。 $ docker tag localimage/ue_server:0.1 {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/ue_server:0.1 $ docker push {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/ue_server:0.1 Amazon EKSにおいてAgones Fleetを作成する ローカルでAgones Fleetの作成用のfleet. yaml ファイルを作成します。 ## fleet.yaml --- apiVersion: "agones.dev/v1" kind: Fleet metadata: name: fleet-ap-northeast-1 spec: replicas: 2 scheduling: Packed strategy: type: RollingUpdate rollingUpdate: maxSurge: 25% maxUnavailable: 25% template: metadata: namespace: meta-poc labels: region: ap-northeast-1 spec: players: initialCapacity: 4 ports: - name: default containerPort: 7654 health: initialDelaySeconds: 30 periodSeconds: 60 template: metadata: namespace: meta-poc labels: region: ap-northeast-1 spec: containers: - name: ue5-server image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/ue_server:0.1 resources: requests: memory: "512Mi" cpu: "500m" limits: memory: "1Gi" cpu: "1" fleet. yaml ファイルを Amazon EKSに適用します。 $ kubectl apply -f fleet.yaml 4.動作確認 UnrealEngine Editorを使用して4つのクライアントを起動する 具体的な操作手順は、以下の図のとおり New Editor Window(PIE) -> Number Of Players: 4 -> Play Standalone を設定する 以下のコマンドでAgones GameServerのステータス監視を起動する $ watch kubectl get gs OpenMatch Frontend Serviceをローカルに マッピング する # OpenMatch Frontend サービスがローカルの8081ポートにマッピングされます kubectl port-forward services/frontend-ednpoint 8081:80 マッチング機能をテストする 確認ポイント: 4つのクライアントが同じGameServerアドレスにマッチングされていること GameServerのステータスがAllocatedになっていること DedicatedServerへの接続をテストする 確認ポイント: Playボタンをクリックした後、Dedicated Serverに成功したこと Characterの頭上に表示されているNickNameが、クライアントが入力したNickNameと同じであること AgonesによるGameServerのシャットダウンをテストする 確認ポイント: 全てのGameClientを閉じた後、以前Allocated状態だったサーバーが削除されること その代わりに新たにReady状態のGameServerが作成されること 終わりに これで、マッチング機能を備え、Agonesで Unreal Engine DedicatedServerをスケジューリングするオンライン マルチプレイヤー ゲームの作成が完了しました。 この一連の記事では、EKSを使って Kubernetes の特性を活用し、 Unreal Engine のDedicated Serverを管理する方法について深く探求しました。 その中で、マッチングシステムとしてOpenMatchを使用し、その強力な拡張性と設定性を活用してニーズに合ったマッチングルールをカスタマイズしました。同時に、Agonesを用いてゲームサーバーのスケジューリングを行い、効率的で安定したサーバー管理を実現しました。 次は、さらなるゲームに関連するインフラ設計のソリューションを見つけ出し、ゲーム体験をさらに向上させる方法を継続に探求します。 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 参考 https://docs.unrealengine.com/5.2/ja/linux-game-development-in-unreal-engine/ https://agones.dev/site/docs/reference/fleet/ 執筆: @chen.sun 、レビュー: @yamashita.yuki ( Shodo で執筆されました )
こんにちは、金融ソリューション事業部の孫です。 前回の Part1 記事に続きまして Part2 では、OpenMatchとAgonesを使用して、柔軟性がありスケーラブルなゲームマッチングとゲームサーバー管理システムの構築方法を詳しく説明しました。 この記事(Part3)では、UnrealEngineを利用して、オンラインマルチプレーヤーゲームのデモを完成させます。 さらに、 Part2 で開発したマッチメイキングサービスをUEクライアント(UnrealEngine GameClient)に統合します。 全体 アーキテクチャ は以下の図の通りです。 UEクライアントの実装について、前の 記事 で作成したデモを基にさらに拡張します。 プレーヤーマッチング機能とサーバー管理機能を追加することで、この度のクライアントの実現を目指します。 実施手順 1.前記事のデモにマッチング機能の追加 マッチング機能のボタンの追加 Frontend APIの呼びだす機能実装 2. Agones SDKを呼び出してGameServerの終了機能の実装 ユーザー数の管理機能の追加 Agones SDKを利用してGameServerの終了実装 3.パッケージ化したUEサーバーでAgones Fleetの作成 4.動作確認 終わりに 参考 実施手順 以下の順序でシステムを構築します: 前記事 のデモにマッチング機能の追加 Agones SDK を呼び出してGameServerの終了機能の実装 パッケージ化したUEサーバーでAgones Fleetの作成 動作確認 1. 前記事 のデモにマッチング機能の追加 この前の記事 UE5ネットワーク同期のC++実装例 では、プレーヤーが「Play」ボタンをクリックすると、UEゲームサーバーに接続してゲーム体験を開始する機能をすでに完成させています。 次に、マッチング機能のボタンを追加し、このボタンをクリックするとOpenMatchの Frontend API を呼び出してマッチング要求をリク エス トします。 マッチング機能のボタンの追加 以下の図のように、テキストエリア(Region入力用)とボタン(「Match」)をUnrealEngineのBluePrint Widget UIに配置します。 Frontend API の呼びだす機能実装 以下のモジュールをxxx.Build.csファイルに追加する 今回では、 Agones / HTTP / Json / JsonUtilities の4つのモジュールを使用します。 各モジュールは次のような役割を果たします。 Agones モジュールは Agones の SDK を使うために使用する HTTP モジュールは Frontend API の http リク エス トを送信するために使用する Json 、 JsonUtilities モジュールは http からの返却 Json データを解析するために使用する //xxx.Build.cs using UnrealBuildTool; //:(省略) PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HeadMountedDisplay", "EnhancedInput", "Agones", "HTTP", "Json", "JsonUtilities" }); //:(省略) LoginHUDWidget.h を編集する まずは、配置したWidgetsをバインドすることから始めます。 ※注意:BluePrint内の Widget 名は LoginHUDWidget.h ファイル内の変数名と同じでなければならず、 meta = (BindWidget) タグを追加して Widget をバインドします それを完了したら、httpモジュールをincludeした上でコールバック関数を作成します。 //LoginHUDWidget.h // "Http.h"ヘッダーファイルの追加 #include "Http.h" UCLASS() class TEST_DESERVER_API ULoginHUDWidget : public UUserWidget { GENERATED_BODY() public: //:(省略) UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UEditableText* regionName; UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UTextBlock* matchLabel; UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UButton* matchBtn; private: FHttpModule* Http; void OnFetchGameServerResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful); } LoginHUDWidget.cpp にマッチングボタンのクリックロジックを実装する //LoginHUDWidget.cpp // "Json.h", "JsonUtilities.h"ヘッダーファイルの追加 #include "Json.h" #include "JsonUtilities.h" // 構造関数内で関連コントロールとHttpモジュールを初期化します // MatchボタンにOnMatchmakingButtonClickedトリガー関数をバインドします void ULoginHUDWidget::NativePreConstruct() { Super::NativePreConstruct(); //:(省略) matchLabel->SetText(FText::FromString("Match")); FScriptDelegate MatchmakingDelegate; MatchmakingDelegate.BindUFunction(this, "OnMatchmakingButtonClicked"); matchBtn->OnClicked.Add(MatchmakingDelegate); //:(省略) Http = &FHttpModule::Get(); } // OnMatchmakingButtonClickedトリガー関数の処理ロジックを追加します void ULoginHUDWidget::OnMatchmakingButtonClicked() { statusLabel->SetText(FText::FromString("Start Matching!! Please wait")); TSharedRef<IHttpRequest, ESPMode::ThreadSafe> FetchGameServerHttpRequest = Http->CreateRequest(); FString frontendUrl = "127.0.0.1:8081/play/" + FString(regionName->GetText().ToString()); FetchGameServerHttpRequest->SetVerb("GET"); FetchGameServerHttpRequest->SetURL(frontendUrl); FetchGameServerHttpRequest->SetHeader("Content-Type", "application/json"); FetchGameServerHttpRequest->OnProcessRequestComplete().BindUObject(this, &ULoginHUDWidget::OnFetchGameServerResponse); FetchGameServerHttpRequest->ProcessRequest(); }; // Httpのコールバック関数の処理ロジックを追加します void ULoginHUDWidget::OnFetchGameServerResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful) { if (bWasSuccessful) { TSharedPtr<FJsonObject> JsonObject; TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString()); if (FJsonSerializer::Deserialize(Reader, JsonObject)) { FString IpAddress = JsonObject->GetStringField("ip"); FString Port = JsonObject->GetStringField("port"); FString LevelName = IpAddress + ":" + Port; statusLabel->SetText(FText::FromString(LevelName)); playBtn->SetVisibility(ESlateVisibility::Visible); } } } // OnPlayGameButtonClickedトリガー関数の処理ロジックを変更します // OpenMatchから返されたGameServerアドレスを接続ターゲットとして設定します void ULoginHUDWidget::OnPlayGameButtonClicked() { //:(省略) FString LevelName = statusLabel->GetText().ToString(); //:(省略) } 2. Agones SDK を呼び出してGameServerの終了機能の実装 すべてのプレイヤーがオフラインになった場合、AgonesSDKを呼び出して利用済のGameServerを削除した後、新しいGameServerを再作成します。 ユーザー数の管理機能の追加 UnrealEngine Gamemodeクラスにおいて PlayerNum 変数を追加して現在のプレーヤー数を保存します。 また、少なくとも1人のプレーヤーがこのGameServerに接続したことがあることを判断するため、 HasPlayerConnected のBool変数を追加します。 前の 記事 で、UnrealEngineによく使われるサーバー側の関数について既にいくつか紹介しました。 今回はその中の PostLogin 、 Logout 関数を用いることで、プレーヤー数の簡易的な統計データが取得します。 //xxxGameMode.h public: //:(省略) virtual void PostLogin(APlayerController* NewPlayer) override virtual void Logout(AController* Exiting) override private: //:(省略) int32 PlayerNum; bool HasPlayerConnected; //xxxGameMode.cpp // 構造関数で変数を初期化します xxxGameMode::xxxGameMode(){ //:(省略) int32 PlayerNum = 0; bool HasPlayerConnected = false; } void xxxGameMode::PostLogin(APlayerController* NewPlayer) { Super::PostLogin(NewPlayer); PlayerNum++; HasPlayerConnected = true; if (!HasPlayerConnected) { HasPlayerConnected = true; GetWorldTimerManager().SetTimer(CountDownPlayerNumHandle, this, &xxxGameMode::TickCount, 1.0f, true); } } void xxxGameMode::Logout(AController* Exiting) { Super::Logout(Exiting); PlayerNum--; } Agones SDK を利用してGameServerの終了実装 UnrealEngine Gamemodeクラスにおいて、Agones SDK のShutdown()関数を呼び出してGameServerがシャットダウンされます。 ※Agonesは一 定量 のGameServerを維持し、GameServerが閉じられると新たなGameServerの生成がトリガーされます。 //xxxGameMode.h public: //:(省略) virtual void Tick(float DeltaTime) override private: //:(省略) //Agones SDKの処理結果に対応するレスポンス関数 //成功処理後のレスポンス関数 void HandleShutdownSuccess(const FEmptyResponse& Response); //成功失敗時のレスポンス関数 void HandleShutdownError(const FAgonesError& Error); void TickCount(); //xxxGameMode.cpp void Atest_DEServerGameMode::HandleShutdownSuccess(const FEmptyResponse& Response) {   //デモのため、実際の処理を行いません UE_LOG(LogTemp, Log, TEXT("Game server successfully shutdown")); } void Atest_DEServerGameMode::HandleShutdownError(const FAgonesError& Error) { //デモのため、実際の処理を行いません UE_LOG(LogTemp, Error, TEXT("shutting down failed: %s"), *Error.ErrorMessage); } void xxxGameMode::TickCount() { Super::Tick(DeltaTime); if (HasPlayerConnected && PlayerNum <= 0) { FShutdownDelegate SuccessDelegate; SuccessDelegate.BindUFunction(this, FName("HandleShutdownSuccess")); FAgonesErrorDelegate ErrorDelegate; ErrorDelegate.BindUFunction(this, FName("HandleShutdownError")); GetWorldTimerManager().ClearTimer(CountDownPlayerNumHandle); AgonesSDK->Shutdown(SuccessDelegate, ErrorDelegate); } } 3.パッケージ化したUEサーバーでAgones Fleetの作成 UnrealEngine Editorを使用して、 Linux プラットフォーム向けのDedicated Serverをパッケージ化します。 以下の図のように、ターゲットプラットフォーム Linux -> Development -> Linux(server) を選択し、パッケージします。 ※ Windows OSではもしかするとターゲットプラットフォームの選択肢に Linux が存在しないかもしれません。 これは、 Linux プラットフォームのサポートが設定されていないためです。 具体的な設定方法については、 こちら を参照してください。 パッケージ化が完了したら、 Part2 で各モジュールをデプロイしたのと同じ手順で、EKSにGameServerをデプロイします。 ローカルにおいてDockerImageを コンパイル する 下記のDockerfileをパッケージ化の際に指定された保存先のルート ディレクト リに配置し、Dockerイメージ作成コマンドを実行します。 ## DockerFile ## 「AgonesOMServer」を実際のプロジェクト名に置き換えます FROM ubuntu:20.04 RUN apt-get update && apt-get install -y \ libxcursor1 \ libxrandr2 \ libxinerama1 \ libxi6 \ libgl1-mesa-glx \ && rm -rf /var/lib/apt/lists/* RUN addgroup --gid 1000 gameserver && \ adduser --gid 1000 --uid 1000 --shell /usr/sbin/nologin --home /home/gameserver --gecos "" --disabled-login --disabled-password gameserver COPY --chown=gameserver:gameserver ./LinuxServer /home/gameserver/LinuxServer RUN chmod -R 770 /home/gameserver/LinuxServer WORKDIR /home/gameserver/LinuxServer USER gameserver EXPOSE 7777/udp ENTRYPOINT ["/home/gameserver/LinuxServer/「AgonesOMServer」.sh"] ## Docker image build command $ docker build -t localimage/ue_server:0.1 . DockerImageを Amazon ECRにアップロードする Part2 と同様に、ue_serverという名前の Amazon ECRプライベー トリポジ トリを新規作成します。 次に、Dockerイメージを Amazon ECRにアップロードします。 $ docker tag localimage/ue_server:0.1 {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/ue_server:0.1 $ docker push {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/ue_server:0.1 Amazon EKSにおいてAgones Fleetを作成する ローカルでAgones Fleetの作成用のfleet. yaml ファイルを作成します。 ## fleet.yaml --- apiVersion: "agones.dev/v1" kind: Fleet metadata: name: fleet-ap-northeast-1 spec: replicas: 2 scheduling: Packed strategy: type: RollingUpdate rollingUpdate: maxSurge: 25% maxUnavailable: 25% template: metadata: namespace: meta-poc labels: region: ap-northeast-1 spec: players: initialCapacity: 4 ports: - name: default containerPort: 7654 health: initialDelaySeconds: 30 periodSeconds: 60 template: metadata: namespace: meta-poc labels: region: ap-northeast-1 spec: containers: - name: ue5-server image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/ue_server:0.1 resources: requests: memory: "512Mi" cpu: "500m" limits: memory: "1Gi" cpu: "1" fleet. yaml ファイルを Amazon EKSに適用します。 $ kubectl apply -f fleet.yaml 4.動作確認 UnrealEngine Editorを使用して4つのクライアントを起動する 具体的な操作手順は、以下の図のとおり New Editor Window(PIE) -> Number Of Players: 4 -> Play Standalone を設定する 以下のコマンドでAgones GameServerのステータス監視を起動する $ watch kubectl get gs OpenMatch Frontend Serviceをローカルに マッピング する # OpenMatch Frontend サービスがローカルの8081ポートにマッピングされます kubectl port-forward services/frontend-ednpoint 8081:80 マッチング機能をテストする 確認ポイント: 4つのクライアントが同じGameServerアドレスにマッチングされていること GameServerのステータスがAllocatedになっていること DedicatedServerへの接続をテストする 確認ポイント: Playボタンをクリックした後、Dedicated Serverに成功したこと Characterの頭上に表示されているNickNameが、クライアントが入力したNickNameと同じであること AgonesによるGameServerのシャットダウンをテストする 確認ポイント: 全てのGameClientを閉じた後、以前Allocated状態だったサーバーが削除されること その代わりに新たにReady状態のGameServerが作成されること 終わりに これで、マッチング機能を備え、Agonesで Unreal Engine DedicatedServerをスケジューリングするオンライン マルチプレイヤー ゲームの作成が完了しました。 この一連の記事では、EKSを使って Kubernetes の特性を活用し、 Unreal Engine のDedicated Serverを管理する方法について深く探求しました。 その中で、マッチングシステムとしてOpenMatchを使用し、その強力な拡張性と設定性を活用してニーズに合ったマッチングルールをカスタマイズしました。同時に、Agonesを用いてゲームサーバーのスケジューリングを行い、効率的で安定したサーバー管理を実現しました。 次は、さらなるゲームに関連するインフラ設計のソリューションを見つけ出し、ゲーム体験をさらに向上させる方法を継続に探求します。 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 参考 https://docs.unrealengine.com/5.2/ja/linux-game-development-in-unreal-engine/ https://agones.dev/site/docs/reference/fleet/ 執筆: @chen.sun 、レビュー: @yamashita.yuki ( Shodo で執筆されました )
こんにちは、金融ソリューション事業部の孫です。 シリーズの最初の記事( Part1 )では、 Kubernetes の強力な機能を活用するためにEKS(Elastic Kubernetes Service)をどのように設定するかについて詳しく説明しました。 EKSの設定が成功した後、ゲームのインフラでよく使われるAgonesとOpen Matchをインストールしました。 また、公式デモでテストを行い、インストールが正しく行われたことを確認しました。 Kubernetes に基づくAgonesとOpen Matchという2つの コンポーネント について、理解していない方がいらっしゃるかもしれません。 そのため、Part2では、まずAgonesとOpen Matchの基本的な概念を簡単に紹介します。 次に、実践でマッチングシステムのデモを作成し、どのようにAgonesとOpen Matchを組み合わせて効率的で柔軟なDedicated Serverの管理とマッチングを実現するかを示します。 Agonesの紹介 OpenMatchの紹介 OpenMatchのマッチメイカーを作成する一般的なフロー OpenMatchとAgonesの統合 実践:ゲームマッチングシステムのデモ作成 マッチングルールの定義 事前準備 マッチング関数の作成 GameFrontend Director Match Profilesの作成 Agones Allocator ServiceによるOpenMatchの統合 AgonesのAllocate機能のパッケージ化 Allocator ServiceパッケージをOpenMatchに統合 MatchFunction デプロイと動作確認 ローカルにおいてDockerImageのコンパイル DockerImageをAmazon ECRにアップロード モジュールをEKSにデプロイ 動作確認 終わりに 参考 Agonesの紹介 Agonesは、 Google Cloudと Ubisoft が共同で開発されて、 Ubisoft 内の大規模な マルチプレイヤー オンラインゲーム(MMO)で利用されているソリューションです。 また、プログラミングが OSS で公開されている為安全に独自ネットワーク内で動作させることが可能です。 Agonesは、 Kubernetes の特性を活用してゲームサーバーを効率的に、そしてスケーラブルに運用および管理する方法を提供します。 Agonesの主要な コンポーネント には、GameServer、Fleetがあります。 Agonesでは、開発者は簡単な Kubernetes のコマンドを用いてGameServerを作成および管理することが可能で、これによりゲームサーバーの管理の複雑さが大幅に低減されます。 Agonesがゲームサーバーのライフサイクルを管理する中で、以下の6つのステージを定義しています。 Agonesはゲームサーバーのステージに応じて、適切な処理を実行します。 Scheduled (予定):GameServerがスケジュールされ、Nodeに割り当てられる Requested (要求): Kubernetes のPodが作成され、GameServerが作成される Starting (起動中):GameServerが起動し、プレイヤーがゲームに接続できる状態になる前の準備状態 Ready (準備完了):GameServerがアクティブ状態で、プレイヤーが接続できる Allocated (割り当て済み):プレイヤーがGameServerに接続し、リソースが確保されている Shutdown (シャットダウン):すべてのプレイヤーが切断され、GameServerがシャットダウンする OpenMatchの紹介 OpenMatchは、Frontend API 、Backend API 、Query API 、Functionなど、複数の コンポーネント から成り立っています。 これらの コンポーネント はそれぞれが独自の役割を果たしながら協調して働き、マッチングシステムを構築します。 OpenMatchのマッチングフローは以下のとおりです。 プレイヤーがFrontend API にマッチングリク エス トを送信する Frontend API はそのリク エス トを内部の状態でストアに保存する マッチング関数がQuery API を使用して状態ストアから条件に合うプレイヤーを問い合わせする マッチング関数がBackend API にマッチング結果を返す Backend API がプレイヤーにマッチング結果を返す OpenMatchのマッチメ イカ ーを作成する一般的なフロー OpenMatchのマッチメ イカ ーを作成するには主に三つのステップがあります。 マッチングルールを定義する マッチング関数を作成する マッチメ イカ ーの設定および運用を行う まず、マッチングルールを定義します。 このルールはマッチングロジックを反映したもので、プレイヤーのレベル、地域、スキルなどを含めます。 次に、マッチング関数を作成します。 この関数はQuery API を使用してマッチングルールに合致するプレイヤーを問い合わせ、そのマッチング結果をBackend API に返します。 最後に、マッチメ イカ ーの設定と運用を行います。OpenMatchは多くの設定オプションを提供しており、それらはニーズに応じて設定できます。 OpenMatchとAgonesの統合 OpenMatchとAgonesの統合は、効率的なゲームマッチングシステムを構築する上での重要な部分であり、主に二つのプロセスが関与しています。 OpenMatchのマッチング関数からAgonesのGameServerを呼びだすところ GameServerのライフサイクルを管理するところ OpenMatchはプレイヤーのマッチングを担当し、一方AgonesはGameServerのライフサイクルの管理を担当します。 これら二つの組み合わせにより、プレイヤーのニーズに応じてGameServerを動的に作成および割り当てることができます。 OpenMatchのマッチング関数内で、Agones SDK を通じて新しいGameServerを作成できます。 しかし、ほとんどの場合新しいGameServerを作成するだけではなく既存のGameServerをスケジュールし、割り当てることがより重要です。 GameServerのパフォーマンス、負荷、地理的な位置などを評価し、最適なGameServerを見つける必要があります。 適切なGameServerを見つけたら、そのアドレスをプレイヤーに返します、プレイヤーはそのアドレスを使用してGame Serverに接続しゲーム体験を始めます。 ここで終わりではありませんが、GameServerの状態を監視し、必要に応じて調整する必要があります。 例えば、GameServerの負荷が高すぎる場合、新しいGameServerを作成して負荷を分散できます。 一方、GameServerのプレイヤー数が減少した場合、それをシャットダウンしてリソースを節約することも可能です。 実践:ゲームマッチングシステムのデモ作成 先に紹介したOpenMatch マッチメーカー の作成プロセスに従って、デモを作成し始めます。 マッチングルールの定義 このデモでは、ユーザーのスキルレベルとレイテンシを基にスコアを算出し、同一リージョン内でスコアが近いユーザーをマッチングするというルールを実装します。 それぞれのマッチングルームは4人のプレイヤーで構成され、スコアが近いユーザー同士は一緒になります。 以下では、このマッチングの詳細や手順、そして適用する アルゴリズム について具体的に説明します。 チケット詳細 Ticket Details チケットは以下図のGameFrontendによって作成され、OpenMatchのFrontendにプッシュされる情報です。 チケットにはプレイヤーに関する情報が含まれており、マッチングの際に使用されます。 以下「GameFrontend」、「Director」、「MatchFunction」章の実装で利用されます。 今回のデモでは、チケット詳細には以下の要素が含まれています。 タグ tag :タグを使ってチケットを分類することが可能で、それによりマッチングシステムはより効率的に対応するキューを見つけることができる 今回はゲームモード Game Mode という設計を前提に実装するため、タグはmode.sessionとする リージョン Region :これはプレイヤーがいる地理的な地域を示しているが、今回はap-northeast-1、ap-northeast-3とする スキルレベル Skill Level :これはプレイヤーのスキルレベルを示しており、0.0から2.0の範囲で設定される レイテンシ Latency :これはプレイヤーのネットワーク遅延を示している ※ほとんどの人は0に近いですが、ネットワークの信頼性をシミュレートするために、一部の人は無限大に設定されている マッチング機能の基準 MatchFunction Criteria 今回のデモでは、マッチングの基準を以下に定義します。 以下「Director」、「MatchFunction」章の実装で利用されます。 まずはプレイヤーの地理的な地域とゲームモードを基準に、チケットプールを作成する 次に、各プレイヤーに対して score = skill - (latency / 1000.0) の アルゴリズム を使用してスコアを算出する そして、スコアに基づいてルームにプレイヤーを配置する 高スキル、低レイテンシのユーザーは同じルームに割り当てられる 1つのマッチに参加できるプレイヤーの上限は、4人と定められている ディレクタープロファイル Director Profiles ディレクタープロファイルはディレクターが生成するオブジェクトで、マッチのリク エス トに使用されます。 以下「Director」章の実装で利用されます。 今回のデモでは、ディレクターは5秒ごとにプロファイルを生成し、マッチをリク エス トする。 また、ディレクターはプレイヤーの地理的な地域に応じて、対応する地理的な地域のGameServerをプレイヤーに割り当てる 事前準備 OpenMatchの リポジトリ をローカル環境にクローンする ベースとなるコードは、tutorials/matchmaker101のパスに存在する git clone https://github.com/googleforgames/open-match.git Golang による実装のため、適切な IDE を設定する この記事では、 Visual Studio Code にGo plugin(v.39.0)をインストールした環境で開発を進めた Docker環境を準備する 対象の環境でDockerをセットアップするには、 Dockerのインストール のドキュメントを参照してください マッチング関数の作成 Openmatch公式ドキュメントの Tutorial をベースに、ステップ バイス テップでGameFrontend、Director、MatchFunctionといった コンポーネント を設計・作成します。 ※TutorialはOpenmatchの フレームワーク プログラムで、マッチングロジックは上記のルールに従って独自に実装する必要があります。 以下の図は、Openmatch の全体的な アーキテクチャ で、赤く囲まれた部分は独自に実装すべき部分です。 GameFrontend チケット生成関数を実装する ※ベースコード: https://github.com/googleforgames/open-match/blob/main/tutorials/matchmaker101/frontend/ticket.go 「マッチングルールの定義」章で定義したチケット詳細 Ticket Details のルールに従って、 mode.session という名前のタグを定義して、次にランダムにスキルとレイテンシの値を設定します。 リージョンの設定については、具体的なユーザーがどこから接続するかは確定していないため、外部から値を取得するように定義します。この情報はクライアントから取得する必要があります。 # ticket.go import( "open-match.dev/open-match/pkg/pb" // 必要なパッケージ追加 "math/rand" "time" ) func makeTicket(region string) *pb.Ticket { modes := []string{"mode.session"} ticket := &pb.Ticket{ SearchFields: &pb.SearchFields{ Tags: modes, DoubleArgs: CreateDoubleArgs(), StringArgs: map[string]string{ "region": region, }, }, } return ticket } func CreateDoubleArgs() map[string]float64 { rand.Seed(time.Now().UTC().UnixNano()) skill := 2 * rand.Float64() latency := 50.0 * rand.ExpFloat64() return map[string]float64{ "skill": skill, "latency": latency, } } echo フレームワーク を使用してFrontendのAPIServerを実装する ※ベースコード: https://github.com/googleforgames/open-match/blob/main/tutorials/matchmaker101/frontend/main.go この API ServerのURLは: GET /play/:region で、regionパラメータを持っています。 # frontend/main.go import( "github.com/labstack/echo" ) type matchResponce struct { IP string `json:"ip"` Port string `json:"port"` Skill string `json:"skill"` Latency string `json:"latency"` Region string `json:"region"` } var fe pb.FrontendServiceClient var matchRes = &matchResponce{} func main() { //:(ベースコートを省略する) // fe = pb.NewFrontendServiceClient(conn)以降のコードをコメントアウト e := echo.New() e.GET("/play/:region", handleGetMatch) e.Start(":80") } func handleGetMatch(c echo.Context) error { // Create Ticket. region := c.Param("region") req := &pb.CreateTicketRequest{ Ticket: makeTicket(region), } matchRes.Skill = fmt.Sprintf("%f", req.Ticket.SearchFields.DoubleArgs["skill"]) matchRes.Latency = fmt.Sprintf("%f", req.Ticket.SearchFields.DoubleArgs["latency"]) matchRes.Region = req.Ticket.SearchFields.StringArgs["region"] resp, err := fe.CreateTicket(context.Background(), req) if err != nil { log.Fatalf("Failed to CreateTicket, got %v", err) return c.JSON(http.StatusInternalServerError, matchRes) } // Polling TicketAssignment. deleteOnAssign(fe, resp) return c.JSON(http.StatusOK, matchRes) } func deleteOnAssign(fe pb.FrontendServiceClient, t *pb.Ticket) { //:(ベースコートを省略する) if got.GetAssignment() != nil { log.Printf("Ticket %v got assignment %v", got.GetId(), got.GetAssignment()) conn := got.GetAssignment().Connection slice := strings.Split(conn, ":") matchRes.IP = slice[0] matchRes.Port = slice[1] break } } Director Match Profilesの作成 ※ベースコード: https://github.com/googleforgames/open-match/blob/main/tutorials/matchmaker101/director/profile.go 「マッチングルールの定義」章で定義した マッチング機能の基準 MatchFunction Criteria のチケットプール作成ルールに従って、 TagPresentFilters および StringEqualsFilter を定義します。 「マッチングルールの定義」章で定義したディレクタープロファイル Director Profiles のゲームサーバー選択ルールに従って、 profile.Extensions を定義します。 # profile.go type AllocatorFilterExtension struct { Labels map[string]string `json:"labels"` Fields map[string]string `json:"fields"` } func generateProfiles() []*pb.MatchProfile { var profiles []*pb.MatchProfile regions := []string{"ap-northeast-1", "ap-northeast-3"} for _, region := range regions { profile := &pb.MatchProfile{ Name: fmt.Sprintf("profile_%s", region), Pools: []*pb.Pool{ { Name: "pool_mode_" + region, TagPresentFilters: []*pb.TagPresentFilter{ {Tag: "mode.session"}, }, StringEqualsFilters: []*pb.StringEqualsFilter{ {StringArg: "region", Value: region}, }, }, }, } // build filter extensions filter := AllocatorFilterExtension{ Labels: map[string]string{ "region": region, }, Fields: map[string]string{ "status.state": "Ready", }, } // to protobuf Struct labelsStruct := &structpb.Struct{Fields: make(map[string]*structpb.Value)} for key, value := range filter.Labels { labelsStruct.Fields[key] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: value}} } fieldsStruct := &structpb.Struct{Fields: make(map[string]*structpb.Value)} for key, value := range filter.Fields { fieldsStruct.Fields[key] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: value}} } // put data to the protobuf Struct filterStruct := &structpb.Struct{Fields: map[string]*structpb.Value{ "labels": {Kind: &structpb.Value_StructValue{StructValue: labelsStruct}}, "fields": {Kind: &structpb.Value_StructValue{StructValue: fieldsStruct}}, }} // to google.protobuf.Any object filterAny, err := ptypes.MarshalAny(filterStruct) if err != nil { panic(err) } profile.Extensions = map[string]*any.Any{ "allocator_filter": filterAny, } profiles = append(profiles, profile) } return profiles } Agones Allocator ServiceによるOpenMatchの統合 Agonesを統合し、マッチング結果に対応するGameServerアドレスを割り当てます。 プレイヤーはこのアドレスを通じて対応するGameServerに接続します。 Agones Allocate機能の実装は独自のものであり、OpenMatchの チュートリアル には基礎となるコードが存在しません。 そのため、directorフォルダの下に新規ファイルとしてallocator_director.goを作成します。 AgonesのAllocate機能のパッケージ化 Agonesが提供する Allocator Service を使って対応するGameServerを取得します。 デフォルトのクライアント証明書を取得する Allocator Service はmTLS認証モードを使用しており、これにより証明書を使用してサービスに接続することは必須になります。 証明書は既にhelmインストール時に作成されています。 独自の証明書を使用することも可能で、具体的な設定は こちら を参照してください。 下記のコマンドを実行してデフォルトのクライアント証明書を取得する ※筆者は Mac の環境でコマンドを実行しています。 Linux の環境であれば、 base64 -D の代わりに base64 -d コマンドを使用してください。 # MACのコマンド kubectl get secret allocator-client.default -n default -ojsonpath="{.data.tls\.crt}" | base64 -D > "client.crt" kubectl get secret allocator-client.default -n default -ojsonpath="{.data.tls\.key}" | base64 -D > "client.key" kubectl get secret allocator-tls-ca -n agones-system -ojsonpath="{.data.tls-ca\.crt}" | base64 -D > "tls-ca.crt" 取得したクライアント証明書を配置します。 上記でダウンロードした証明書を特定のパスに保存し、Pathとして定義します。 ここでは、 allocator/certfile ディレクト リに保存することを想定しています。 # agones_allocator.go const ( KeyFilePath = "allocator/certfile/client.key" CertFilePath = "allocator/certfile/client.crt" CaCertFilePath = "allocator/certfile/tls-ca.crt" ) Allocator Serviceのクライアントを作成する 外部からAgonesのAllocate機能を呼びだす必要がある場合、 NewAgonesAllocatorClient を呼びだすとクライアントが生成されます。 ※ご注意: コードが長くなりすぎないように、エラー処理に関連するコードは削除しています。 # agones_allocator.go type AgonesAllocatorClientConfig struct { KeyFile string CertFile string CaCertFile string AllocatorServiceHost string AllocatorServicePort int Namespace string MultiCluster bool } type AgonesAllocatorClient struct { Config *AgonesAllocatorClientConfig DialOpts grpc.DialOption } func NewAgonesAllocatorClient() (*AgonesAllocatorClient, error) { config := &AgonesAllocatorClientConfig{ KeyFile: KeyFilePath, CertFile: CertFilePath, CaCertFile: CaCertFilePath, AllocatorServiceHost: AllocatorServiceHost, AllocatorServicePort: AllocatorServicePort, Namespace: "default", MultiCluster: false, } cert, err = ioutil.ReadFile(config.CertFile) key, err = ioutil.ReadFile(config.KeyFile) ca, err = ioutil.ReadFile(config.CaCertFile) dialOpts, err := createRemoteClusterDialOption(cert, key, ca) return &AgonesAllocatorClient{ Config: config, DialOpts: dialOpts, }, nil } func createRemoteClusterDialOption(clientCert, clientKey, caCert []byte) (grpc.DialOption, error) { cert, err := tls.X509KeyPair(clientCert, clientKey) tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true} if len(caCert) != 0 { tlsConfig.RootCAs = x509.NewCertPool() if !tlsConfig.RootCAs.AppendCertsFromPEM(caCert) { return nil, errors.New("only PEM format is accepted for server CA") } } return grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)), nil } Allocateのメイン関数を実装する この関数では、まずgrpc( grpc.Dial ) プロトコル を使用して Allocator Service に接続します。 次に、GameServerの選択ルール( assignmentGroup.Assignment.Extensions )を取得します。 このルールに基づいて対応するGameServerを取得し、最終的に各Assignmentの address フィールドにアドレスを付与します。 ※ご注意: コードが長くなりすぎないように、エラー処理に関連するコードは削除しています。 # agones_allocator.go func (c *AgonesAllocatorClient) Allocate(req *pb.AssignTicketsRequest) error { conn, err := grpc.Dial(fmt.Sprintf("%s:%d", c.Config.AllocatorServiceHost, c.Config.AllocatorServicePort), c.DialOpts) defer conn.Close() grpcClient := pb_agones.NewAllocationServiceClient(conn) for _, assignmentGroup := range req.Assignments { filterAny := assignmentGroup.Assignment.Extensions["allocator_filter"] filter := &structpb.Struct{} if err := ptypes.UnmarshalAny(filterAny, filter); err != nil { panic(err) } request := &pb_agones.AllocationRequest{ Namespace: c.Config.Namespace, GameServerSelectors: []*pb_agones.GameServerSelector{ { MatchLabels: filter.Fields["labels"], }, }, MultiClusterSetting: &pb_agones.MultiClusterSetting{ Enabled: c.Config.MultiCluster, }, } resp, err := grpcClient.Allocate(context.Background(), request) if len(resp.GetPorts()) > 0 { address := fmt.Sprintf("%s:%d", resp.Address, resp.Ports[0].Port) assignmentGroup.Assignment.Connection = address } } return nil } Allocator ServiceパッケージをOpenMatchに統合 上記のOpenMatchの アーキテクチャ 図に基づき、Agonesサービスを呼び出してGameServerを取得する コンポーネント はDirectorです。 そのため、呼び出しコードをDirectorに統合する必要があります。 ※ベースコード: https://github.com/suecideTech/try-openmatch-agones/blob/master/OpenMatch/mod_matchmaker101/director/main.go GameServerのassgin関数を修正します。 ここでは、上記で作成した Allocator Service パッケージを使用して実際のGameServerアドレスを取得します。 元のコードでは GameServerAllocations を使ってGameServerアドレスを取得していますが、これはAgonesが推奨している方法ではなく、また後期の拡張にも適していません。 そのため、公式に推奨されている Allocator Service をラップしてGameServerを取得するようにしました。 # director/main.go func assign(be pb.BackendServiceClient, matches []*pb.Match) error { for _, match := range matches { ticketIDs := []string{} for _, t := range match.GetTickets() { ticketIDs = append(ticketIDs, t.Id) } aloReq := &pb.AssignTicketsRequest{ Assignments: []*pb.AssignmentGroup{ { TicketIds: ticketIDs, Assignment: &pb.Assignment{ Extensions: match.Extensions, }, }, }, } client, err := allocator.NewAgonesAllocatorClient() client.Allocate(aloReq) if _, err := be.AssignTickets(context.Background(), aloReq); err != nil { return fmt.Errorf("AssignTickets failed for match %v, got %w", match.GetMatchId(), err) } log.Printf("Assigned server %v to match %v", conn, match.GetMatchId()) } return nil } MatchFunction ユーザーマッチングルールを実装します。 ※ベースコード: https://github.com/suecideTech/try-openmatch-agones/blob/master/OpenMatch/mod_matchmaker101/matchfunction/mmf/matchfunction.go 「マッチングルールの定義」章で定義した マッチング機能の基準 MatchFunction Criteria のユーザーマッチングルールに従って、まずユーザーのスコアを計算し、スコアの大きさに基づいて4人部屋を割り当てます。 # matchfunction.go const ( matchName = "basic-matchfunction" ticketsPerPoolPerMatch = 4 ) func (s *MatchFunctionService) Run(req *pb.RunRequest, stream pb.MatchFunction_RunServer) error { //:(ベースコートを省略する) //poolTickets, err := matchfunction.QueryPools(stream.Context(), s.queryServiceClient, req.GetProfile().GetPools()) p := req.GetProfile() tickets, err := matchfunction.QueryPool(stream.Context(), s.queryServiceClient, p.GetPools()[0]) //:(ベースコートを省略する) idPrefix := fmt.Sprintf("profile-%v-time-%v", p.GetName(), time.Now().Format("2006-01-02T15:04:05.00")) proposals, err := makeMatches(req.GetProfile(), idPrefix, tickets) //:(ベースコートを省略する) } func (s *MatchFunctionService) makeMatches(ticketsPerPoolPerMatch int, profile *pb.MatchProfile, idPrefix string, tickets []*pb.Ticket) ([]*pb.Match, error) { if len(tickets) < ticketsPerPoolPerMatch { return nil, nil } ticketScores := make(map[string]float64) for _, ticket := range tickets { ticketScores[ticket.Id] = score(ticket.SearchFields.DoubleArgs["skill"], ticket.SearchFields.DoubleArgs["latency"]) } sort.Slice(tickets, func(i, j int) bool { return ticketScores[tickets[i].Id] > ticketScores[tickets[j].Id] }) var matches []*pb.Match count := 0 for len(tickets) >= ticketsPerPoolPerMatch { matchTickets := tickets[:ticketsPerPoolPerMatch] tickets = tickets[ticketsPerPoolPerMatch:] var matchScore float64 for _, ticket := range matchTickets { matchScore += ticketScores[ticket.Id] } eval, err := anypb.New(&pb.DefaultEvaluationCriteria{Score: matchScore}) if err != nil { log.Printf("Failed to marshal DefaultEvaluationCriteria into anypb: %v", err) return nil, fmt.Errorf("Failed to marshal DefaultEvaluationCriteria into anypb: %w", err) } newExtensions := map[string]*anypb.Any{"evaluation_input": eval} newExtensions for k, v := range origExtensions { newExtensions[k] = v } matches = append(matches, &pb.Match{ MatchId: fmt.Sprintf("%s-%d", idPrefix, count), MatchProfile: profile.GetName(), MatchFunction: matchName, Tickets: matchTickets, Extensions: newExtensions, }) count++ } return matches, nil } func score(skill, latency float64) float64 { return skill - (latency / 1000.0) } ここまでで、マッチング機能とGameServerのケジューリング機能の実装が完了しました。 次に、作成したモジュールをそれぞれEKSにアップロードし、デモとしてテストします。 具体的に完成したデモの アーキテクチャ は、以下の図の通りです。 デプロイと動作確認 ローカルにおいてDockerImageの コンパイル GameFrontend # OpenMatch/mod_matchmaker101/frontend/Dockerfile docker build -t localimage/mod_frontend:0.1 . Director # OpenMatch/mod_matchmaker101/director/Dockerfile docker build -t localimage/mod_director:0.1 . MatchFunction # OpenMatch/mod_matchmaker101/matchfunction/Dockerfile docker build -t localimage/mod_matchfunction:0.1 . DockerImageを Amazon ECRにアップロード EKSでDockerImageを取得する際、ローカルのイメージにアクセスできないため、イメージを Amazon ECRサービスにアップロードする必要があります。 ※ Amazon ECRはイメージを保管するための専用レポジトリで、Docker Hubなどと同様のサービスがあります。 Amazon ECRでプライベートイメージ リポジトリ を作成する具体的な方法については、 ECRでプライベートリポジトリを作成する を参照してください。 以下の名称の Amazon ECRプライベートイメージ リポジトリ をそれぞれ作成する Frontendの リポジトリ 名:mod_frontend Directorの リポジトリ 名:mod_director MatchFunctionの リポジトリ 名:mod_matchfunction ローカルのイメージを Amazon ECRにアップロードする # Frontend docker tag localimage/mod_frontend:0.1 {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_frontend:0.1 docker push {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_frontend:0.1 # Director docker tag localimage/mod_director:0.1 {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_director:0.1 docker push {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_director:0.1 # MatchFunction: docker tag localimage/mod_matchfunction:0.1 {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_matchfunction:0.1 docker push {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_matchfunction:0.1 モジュールをEKSにデプロイ デプロイ yaml ファイル内のimageアドレスを、上記で作成した Amazon ECRのアドレスに変更します。 # Frontend: ## yaml file path ## OpenMatch/mod_matchmaker101/frontend/frontend.yaml image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_frontend:0.1 # Director ## yaml file path ## OpenMatch/mod_matchmaker101/director/director.yaml image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_director:0.1 # MatchFunction: ## yaml file path ## OpenMatch/mod_matchmaker101/matchfunction/matchfunction.yaml image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_matchfunction:0.1 各 Yaml ファイルを以下のように修正します。 # Frontend.yaml ## yaml file path ## OpenMatch/mod_matchmaker101/frontend/frontend.yaml ## KindをDeploymentに変更し、HTTP LBサービスを追加します apiVersion: v1 kind: Service metadata: name: frontend-endpoint annotations: service.alpha.kubernetes.io/app-protocols: '{"http":"HTTP"}' labels: app: frontend spec: type: NodePort selector: app: frontend ports: - port: 80 protocol: TCP name: http targetPort: frontend --- apiVersion: apps/v1 kind: Deployment metadata: name: frontend namespace: default labels: app: frontend spec: replicas: 1 selector: matchLabels: app: frontend template: metadata: labels: app: frontend spec: containers: - name: frontend image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_frontend:0.1 imagePullPolicy: Always ports: - name: frontend containerPort: 80 # Director.yaml ## yaml file path ## OpenMatch/mod_matchmaker101/director/director.yaml apiVersion: v1 kind: Pod metadata: name: director namespace: openmatch-poc spec: containers: - name: director image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_director:0.1 imagePullPolicy: Always hostname: director # MatchFunction.yaml ## yaml file path ## OpenMatch/mod_matchmaker101/matchfunction/matchfunction.yaml apiVersion: v1 kind: Pod metadata: name: matchfunction namespace: openmatch-poc labels: app: openmatch component: matchfunction spec: containers: - name: matchfunction image: {AWS ACCOUNT ID}.dkr.ecr.{AWS REGION}.amazonaws.com/mod_MatchFunction:0.1 imagePullPolicy: Always ports: - name: grpc containerPort: 50502 --- kind: Service apiVersion: v1 metadata: name: matchfunction namespace: openmatch-poc labels: app: openmatch component: matchfunction spec: selector: app: openmatch component: matchfunction clusterIP: None type: ClusterIP ports: - name: grpc protocol: TCP port: 50502 それぞれの yaml ファイルをEKSに適用し、マッチングシステムをデプロイします。 # GameFrontend kubectl apply -f frontend.yaml # Director kubectl apply -f director.yaml # MatchFunction kubectl apply -f matchfunction.yaml 動作確認 以下の図に示すように、 frontend.yaml で作成されたServiceに接続し、マッチングシステムをテストします。 この frontend-endpoint サービスにローカルでもアクセスできるようにするため、 Kubernetes のPortForwadering機能を使用します。 Frontendサービスをローカルに マッピング する 下図の⑤エリアです。 # Frontend サービスをローカルの8081ポートにマッピングします kubectl port-forward services/frontend-ednpoint 8081:80 8つの新しいターミナルを作成して、8名のプレイヤーがFrontendサービスに接続するのをシミュレートする 下図の①エリアで4名(ap-northeast-1)+4名(ap-northeast-3)のプレイヤーが接続するのをシミュレートします。 # Get: /Frontend/play/regionname ## 4名(ap-northeast-1) curl 127.0.0.1:8081/play/ap-northeast-1 ## 4名(ap-northeast-3) curl 127.0.0.1:8081/play/ap-northeast-3 エラーの有無を確認するために、matchfunction/director/frontendモジュールのログ情報を出力する 下図の②〜④エリアです。 # matchfuntion log kubectl logs --tail 4 matchfunction -n openmatch-poc # director log kubectl logs --tail 4 director -n openmatch-poc # frontend log kubectl logs --tail 4 deployments/frontend 上記の手順に従って、8名のプレイヤーがマッチングを開始するシミュレーションを行います。 確認ポイントは次の通りです。 ②〜④エリアのログにはエラー出力がありません。 ①の8名のクライアント全員がIP、Port情報を正常に取得します。 また、前の4名のプレイヤーは同じグループにマッチされるため、その IP:Port アドレスは同じです。 後の4名のプレイヤーも同じグループにマッチされ、その IP:Port アドレスも同じです。 ⑥エリアでは、GameServerのステータスを確認し、2台のサーバーがAllocated状態にあることを確認します。 終わりに これまでに、AgonesとOpen Matchを使用して高可用性と拡張性、スケジューリングが可能なマッチングシステムを構築しました。 次に、 Part3 ではUnrealEngineを使用してGameClientを開発し、このマッチングシステムに接続する方法を説明します。 これにより、マッチング機能を持ち、Agonesを使用してDedicated Serverをスケジューリングする マルチプレイ ゲームの開発を完了します。 引き続き、お楽しみにしてください! 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 参考 https://agones.dev/site/docs/overview/ https://open-match.dev/site/docs/ 執筆: @chen.sun 、レビュー: @yamashita.yuki ( Shodo で執筆されました )
こんにちは!金融ソリューション事業部の孫です。 以前の 記事 では Unreal Engine Dedicated Serverの構築方法について紹介しました。 今回は続きの記事として、以下の3部で、 AWS が提供するEKSを使用してマッチメイキング機能を持つAgonesでGameServerを運用する環境の構築プロセスを説明します。 なお、EKSは Kubernetes の クラウド サービスの一つであり、同様のものとして「 Google のGKE」や「 Microsoft の AKS 」などが存在します。 以下3記事(Part)に分けて解説します。 Part1 :環境設定(「EKS環境構築」と「Agones/Open Matchのインストール」)、および動作確認 Part2 :マッチメイキングサービスのカスタマイズ処理を実装します Part3 :UnrealEngineを使用したGameServerとGameClientを用意します、Part2で開発したマッチメイキングサービスをGameClientに統合します。 はじめに 実行環境 実施手順 1.EKSの環境構築 2.Agonesのインストール 3.Openmatchのインストール 4.動作確認 Agonesデモのデプロイと動作確認 Open Matchデモのデプロイと動作確認 終わりに 参考 はじめに 現代のゲーム業界では、Dedicated Serverが重要な要素となっています。ある マルチプレイ ゲームの裏側では、大量のDedicated Serverを所有しており、プレイヤーに安定かつ効率的なサービスを提供します。 これらのサーバーの管理とスケジューリングは、プレイヤーのゲーム体験に直接影響する重要な問題です。この問題を解決するために、Agonesは登場し、Dedicated Serverの管理とスケジューリングをよりシンプルにするソリューションを提供します。 しかし、専用サーバーの管理とスケジューリングだけでは不十分で、これらのサーバーを効率的に利用するためにはマッチングシステムが必要です。 例えば、同じ地域のプレイヤーが最も近いゲームサーバーにマッチングされることで、彼らのゲーム体験を向上させることを望んでいます。そのため、Open Matchは、柔軟でスケーラブルなマッチングシステムとして生まれました。 したがって、AgonesとOpenMatchは相互補完的な存在です。一方で、AgonesはDedicated Serverの管理とスケジューリングを行い、サーバーの使用効率を向上させます。 一方で、Open Matchは効率的なマッチング アルゴリズム を通じて、プレイヤーを最も適したサーバーにマッチングします。 これら二つは、ゲームに対して柔軟でスケーラブルで効率的なインフラスト ラク チャを提供します。 これから、このようなインフラスト ラク チャを作成する方法を3記事に分けてステップ バイス テップで説明します。 実行環境 kubectl (v1.27.1) Kubernetes クラスタ ーを操作するための コマンドライン ツール eksctl (0.139.0) EKS クラスタ ーを制御するために使用する コマンドライン ツール AWS CLI (2.11.17) Amazon EKS など AWS のサービスを操作するための コマンドライン ツール 実施手順 以下の手順で環境を構築し、テストを行います。 EKSの環境構築 Agonesのインストール Openmatchのインストール 動作確認 1.EKSの環境構築 EKSの構築方法については、 AWS 公式ドキュメントの 「Amazon EKS の開始方法」 に従って以下の手順を実施します。 EKS展開用のネットワーク環境を作成する Kubernetes クラスタ を作成する クラスタ のノードグループを作成する これから、EKSの構築を実施します。 a.EKS展開用のネットワーク環境を作成する 今回は検証目的のため、 公式サイト で推奨されるネットワーク アーキテクチャ (PrivateとPublicの構造)は使用せず、2つのPublicネットワークのみを設定します。 実施結果を確認します。 VPC CIDR: 192.168.0.0/16 Subnet: $ aws ec2 describe-subnets \ --filters "Name=vpc-id,Values=[VPC ID]" \ --query 'Subnets[*].{SubnetId: SubnetId,AvailabilityZone: AvailabilityZone,CidrBlock: CidrBlock}' \ --output table -------------------------------------------------------------------- | DescribeSubnets | +------------------+------------------+----------------------------+ | AvailabilityZone | CidrBlock | SubnetId | +------------------+------------------+----------------------------+ | ap-northeast-1a | 192.168.0.0/24 | パブリックサブネットID1 | | ap-northeast-1c | 192.168.1.0/24 | パブリックサブネットID2 | +------------------+------------------+----------------------------+ b. Kubernetes クラスタ を作成する ここでは、 クラスタ を作成する方法として「eksctl」と「マネジメントコンソール」の2つの選択肢があります。 初心者がステップ バイス テップで進めたい場合は後者のマネジメントコンソールを使用することをおすすめしますが、今回は手間を省略するためにeksctlコマンドを使用して クラスタ を作成します。 以下のコマンドを使用して クラスタ を作成しますが、「 クラスタ 名」・「パブリックサブネットID1」・「パブリックサブネットID2」はそれぞれ実際の クラスタ 名とパブリックサブネットIDに置き換えてください # コマンドガイドライン ## https://eksctl.io/usage/vpc-configuration/#use-existing-vpc-other-custom-configuration eksctl create cluster --name 「クラスタ名」\ --region ap-northeast-1 \ --without-nodegroup \ --vpc-public-subnets=[パブリックサブネットID1],[パブリックサブネットID2] c. クラスタ のノードグループを作成する NodeGroupを AWS コンソールを介して作成する場合、まず一連のロールを設定する必要があります。手続きを簡略化するために、今回も引き続きeksctlコマンドを使用してNodeGroupを作成します。 以下のコマンドを使用してNodeGroupを作成します。「 クラスタ 名」「ノードグループ名」はそれぞれ希望する名前に置き換えてください。 ※次にOpen Matchをインストールしますが、インストールコマンドを簡略化するために、デフォルトの設定を使用します。このため、最低でも3つ以上のCPUが必要となりますので、今回はt3.xlargeタイプのサーバを選択します。 # コマンドガイドライン ## https://eksctl.io/usage/managing-nodegroups/ eksctl create nodegroup \ --cluster 「クラスタ名」 \ --region ap-northeast-1 \ --name 「ノードグループ名」 \ --node-type t3.xlarge \ --nodes 2 \ --nodes-min 2 \ --nodes-max 2 ここまでは、EKSの環境設定が完了しました。 次に、AgonesとOpen Matchをインストールします。 2.Agonesのインストール Agonesをインストールする方法については、基本的には Agonesの公式ドキュメンテーション に従います。 インストール方法としては、 yaml インストールとHelmインストールの2つの方法がありますが、以下の理由からHelmを使用してAgonesをインストールする方法を選択しました。 ① Helmを使用すると、 Kubernetes アプリケーションのデプロイを再現可能で一貫性のある形で行うことができます。これにより、運用上の一貫性と信頼性を確保する ② Helmはチャートと呼ばれるパッケージ形式を使用するため、チーム間での共有や再利用が容易になる ③ 複雑なアプリケーションを構成するためのパラメータ化された設定を提供します。これにより、カスタマイズや再構成が容易になる ④ Helmはデプロイのバージョン管理、つまりリリースの管理を容易にします。これにより、バージョン間の移行がスムーズに行う Agonesのインストールコマンドは以下のとおりです。 [my-release]はリリースの名前で、適宜置き換えてください。 # 公式のstable Helmリポジトリを追加 helm repo add agones https://agones.dev/chart/stable # Helmを使ってAgonesをインストール helm install [my-release] --namespace agones-system agones/agones 上記のコマンドを使用すると、公式のstable Helm リポジトリ を追加し、Helmを使ってAgonesをインストールできます。[my-release]の部分にはインストールするリリースの名前を指定します。 また、Agonesは agones-system という名前の空間にインストールされます。 3.Openmatchのインストール Openmatchの公式ドキュメンテーション に従ってOpenmatchをインストールします。 OpenMatchのインストールコマンドは以下のとおりです。 [my-release]はリリースの名前で、適宜置き換えてください。 # Open MatchのHelmリポジトリを追加 helm repo add open-match https://open-match.dev/chart # Helmを使ってOpen Matchをインストール helm install [my-release] open-match/open-match --namespace open-match --set open-match-core.enabled=true 上記のコマンドを使用すると、Open MatchのHelm リポジトリ を追加し、Helmを使ってOpen Matchをインストールできます。 また、Open Matchはopen-matchという 名前空間 にインストールされ、 open-match-core が有効になります。 4.動作確認 環境の構築が完了したので、AgonesとOpen Matchが正しくインストールされていることを確認するために、AgonesのデモとOpen Matchのデモを使用してテストを行います。 環境の構築が完了している場合は、以下の手順を実行してテストを行ってください。 Agonesデモのデプロイと動作確認 Agones公式ドキュメントの ゲームサーバ作成手順 に従ってサンプルゲームサーバーをデプロイします。これにより、Agonesが正常に動作するかを確認できます。 サンプルゲームサーバーをデプロイする kubectl create -f https://raw.githubusercontent.com/googleforgames/agones/release-1.32.0/examples/simple-game-server/gameserver.yaml GameServerの状態を確認する kubectl get gs 確認ポイントとして、GameServerのステータスが Ready になっていることを確認してください。 $ kubectl get gs NAME STATE ADDRESS PORT NODE AGE simple-game-server-h4h2w Ready ec2-18-182-6-144.ap-northeast-1.compute.amazonaws.com 7393 ip-192-168-0-130.ap-northeast-1.compute.internal 18s Open Matchデモのデプロイと動作確認 Openmatch 公式デモの作成手順 に従ってOpen Matchのデモをデプロイします。これにより、Open Matchが正常に動作するかを確認できます。 サンプルマッチメイキングフロントエンドとバックエンドをデプロイする kubectl create namespace open-match-demo kubectl apply --namespace open-match-demo \ -f https://open-match.dev/install/v1.7.0/yaml/02-open-match-demo.yaml マッチメイキングフロントエンドとバックエンドの状態を確認する kubectl get pods -n open-match-demo 上記の手順に従い、Open Matchのサンプルマッチメイキングでフロントエンドとバックエンドをデプロイし、 kubectl get pods -n open-match-demo コマンドでポッドの状態を確認します。 確認ポイントとして、 ① すべてのポッドのステータスが Running になっていることを確認する $ kubectl get pods -n open-match-demo NAME READY STATUS RESTARTS AGE om-demo-7bf887b848-75nvn 1/1 Running 0 35s om-function-58b954cf5f-4gqr4 1/1 Running 0 35s om-function-58b954cf5f-ll6pz 1/1 Running 0 35s om-function-58b954cf5f-nqrks 1/1 Running 0 35s ② PortForward を使用してデモページにアクセスできることを確認する $ kubectl port-forward --namespace open-match-demo service/om-demo 51507:51507 Forwarding from 127.0.0.1:51507 -> 51507 Forwarding from [::1]:51507 -> 51507 終わりに 以上で、EKSの環境構築およびAgones/Open Matchのインストールプロセスの説明が完了しました。また、公式デモを使用してテストを行いました。 次に、マッチメイキングサービスの構築とGameClientの実装について詳しく説明します。 Part2 と Part3 では、マッチメイキングサービスのカスタマイズ方法とGameClientの開発について取り上げます。 引き続き、お楽しみにしてください! 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 参考 https://docs.aws.amazon.com/ja_jp/eks/latest/userguide/getting-started-console.html https://open-match.dev/site/docs/ https://agones.dev/site/docs/ 執筆: @chen.sun 、レビュー: @yamashita.yuki ( Shodo で執筆されました )
はじめに 前提 この記事の対象とする読者の方 スポットインスタンスについて 2つのシグナル 2分前中断シグナル 再調整推奨シグナル スポットインスタンスのリソース確保 マネージド型ノードグループの利用 Mixed Instance Policyの利用 Cluster Autoscalerを有効にする。 ノードグループのAZごとの分散 Priority Expander スポットインスタンスの中断に備える ワークロードを停止させないために考慮すること Graceful Shutdownされるようになっている。 Podが必要な数を維持したままドレインされる。 Podが正常であるという状態を適切に定義する。 Pod Readiness Gate Podの可用性を考慮したスケジュール まとめ おわりに はじめに こんにちは!2年目にもかかわらずフレックス制度を存分に活用して、普段8:30 – 16:30で業務をしている クラウド イノベーション センターの石井大樹です。 Amazon EKS(以下EKS)にて、スポット インスタンス は利用されていますか? スポット インスタンス は安いけど、いきなりシャットダウンしちゃうから、使い勝手が悪いんでしょう?など、漠然とした不安から利用をためらっていたりはしないでしょうか。 そのぼんやりとした不安を払しょくし、スポット インスタンス 利用を検討する足がかりになれば、と思い今回は記事にまとめさせていただきました。 前提 この記事の対象とする読者の方 Kubernetes クラスタ をEKSで運用している方 EKS環境でスポット インスタンス を利用しようとしている方 既にEKS環境でスポット インスタンス を利用している方 ワークロードを維持する方法に関心がある方 スポット インスタンス について スポット インスタンス は、通常のオンデマンド インスタンス に比べて最大90%の割引で利用できるオプションです。 しかし、オンデマンド インスタンス と異なり、 AWS 側のリソースキャパシティの都合により、 インスタンス の利用が中断される可能性があります。 2つのシグナル 先ほどスポット インスタンス は、「 AWS 側の都合により中断される可能性がある」とお話しいたしました。しかし、実際には2つのシグナルが発出された後に中断が実行されます。このシグナルを利用することで、スポット インスタンス の中断の影響を最小限に収めることができます。 2分前中断シグナル スポット インスタンス の終了の2分前に通知されるものです。この通知を受け取って2分後に、スポット インスタンス は終了します。 再調整推奨シグナル 対象スポット インスタンス の中断可能性の高まりが検知された場合、2分前シグナルを受け取る前に通知されるシグナルです。このシグナルを受け取ったタイミングで必要な処理が行われるように事前に準備することで、スポット インスタンス を安定して安全に運用できます。 Amazon EC2 AutoScalingグループのCapacity Rebalancingが有効化されている場合、再調整推奨シグナルを受け取ると、 Amazon EC2 AutoScalingは、代替となる新しい インスタンス の起動を試みます。その 新しい インスタンス が起動した後、既存の インスタンス はシャットダウンされます。 なお、EKSノードグループのAutoScalingグループでCapacity Rebalancingを有効化したい場合は、後述するマネージド型ノードグループを利用することで自動的に有効化されます。セルフマネージド型ノードグループで利用をする際は、手動で設定をする必要があります。 また、セルフマネージド型ノードグループにて、再調整推奨シグナルを受け、後述するGraceful Shutdownなどのアクションの実行をするには、 AWS Node Termination Handler などの導入が必要です。セルフマネージド型ノードグループには、 シグナルをキャッチしてノード上のPodの安全な待避を行う仕組みがないためです。 ※注意 この再調整推奨シグナルは2分前中断シグナルよりも前に通知されるということを保証していません。予期せぬリソースの需要が AWS 側で生まれた場合は、2分前中断シグナルと同時に通知を受け取り、必要な対処ができずにワークロードが中断される可能性があります。 再調整推奨シグナルについて スポット インスタンス のリソース確保 せっかくスポット インスタンス の利用を開始したのにも関わらず、スポット インスタンス のリソースが十分に確保できずにワークロードを中断してしまっては元も子もありません。この章では、スポット インスタンス を利用しながら十分なリソースを確保し続ける方法をこちらでご紹介します。 マネージド型ノードグループの利用 EKSでは、マネージド型ノードグループを利用することにより、様々な恩恵を開発者は受けることができます。この恩恵はスポット インスタンス の利用時も例外ではありません。これを利用することにより、EKSでスポット インスタンス を利用する際に AWS がベストプ ラク ティスとして定めた内容を自動的に設定してくれます。 以下が対象の設定内容の一部分です。 配分戦略を capacity-optimized にする。 これにより、中断確率の低いスポット インスタンス が配分されます。 Capacity Rebalancing を有効化。 これにより、 再調整推奨シグナル を受けたときに、EKSは自動的に中断可能性の低いスポットキャパシティプールからスポット インスタンス を起動し、そのノードが準備完了になったら、既存のスポット インスタンス のドレインをしてくれます。 Mixed Instance Policyの利用 複数のEC2 インスタンス タイプをノードグループで利用できるようにすることで、スポット インスタンス を利用できる確率が高まります。利用候補となる インスタンス タイプが増えるためです。しかし、注意することが1点あります。それは、vCPU・メモリが同等の インスタンス タイプであるべきということです。 例えば、 CPU: 2vCPU , メモリ: 4GiB の c5a.large を利用する際は、CPU: 2vCPU , 、メモリ: 4GiB の c5d.large 等と利用されるべきということです。 vCPU・メモリが異なる インスタンス タイプを混在させると、Cluster Autoscalerが正常にリソースの計算ができなくなり、意図しないスケールにつながってしまいます。 Cluster Autoscalerを有効にする。 Cluster Autoscalerは Kubernetes クラスタ を自動的に調整してくれる非常に有効なエコシステムです。このエコシステムは導入のみでも十分な効果を発揮しますが、以下に紹介する機能を利用することでさらなる効果が期待できます。 ※Auto Scalingグループの最大・最少ノード数は尊重されます。そのため、Cluster Autoscalerは最大ノード数以上・最少ノード数以下にはスケールできません。 ノードグループのAZごとの分散 ノードグループを、利用しているリージョンのAZを跨るように作成するようにします。利用候補となる インスタンス が増えるためです。そうすることで、スポット インスタンス を利用できる確率を高めることができます。 Priority Expander しかし、どれだけ対策を練っても、スポット インスタンス が確保できない可能性があります。そんなときは、オンデマンド インスタンス を代わりに確保するようにしましょう。値段は割引がされていない定価で利用することになりますが、ワークロードを中断させないことが最優先です。 Cluster AutoscalerのPriority Expander を利用することで、スポット インスタンス が見つからない際は、オンデマンド インスタンス のノードグループをスケールさせることで不足したリソースを補填できます。 以下が設定例です。 正規表現 で spot に該当するノードグループが優先してスケールされるように設定しています。 apiVersion : v1 kind : ConfigMap metadata : name : cluster-autoscaler-priority-expander namespace : kube-system data : priorities : |- 50 : - .*spot.* 1 : - .*ondemand.* スポット インスタンス の中断に備える ここまでは、できるだけスポット インスタンス を多くの割合で利用する方法をご紹介いたしました。 ご紹介した通り、スポット インスタンス は中断させられてしまうものです。この中断の際にワークロードの実体であるPodに対してなんにも考慮に入れていない場合、ワークロードは中断してしまいます。 ここからはスポット インスタンス が切り替わる際にもワークロードを安全に保つための方法をご紹介します。 ワークロードを停止させないために考慮すること ワークロードを中断させないために考慮する必要があることが4つあります。 PodはGraceful Shutdownされるようになっている。 Podは必要な数を維持したままドレインされる。 Podが正常であるという状態を適切に定義されている。 Podの可用性を考慮してスケジュールされるようになっている。 Graceful Shutdownされるようになっている。 再調整推奨シグナル を受け取り、EKSがノードのドレインを開始して、瞬時にPodが削除されてしまった場合、問題が発生する可能性があります。その問題について理解するには、まずPodの削除が始まると何が起こるかを理解する必要があります。 Podの削除が始まると以下が行われます。 Podの終了処理 ServiceからのPodの切り離し これらが、問題の生じない順序で行われたら良いのですが、 Kubernetes ではこれらは個別のプロセスとして別々に行われるので、それは保証されません。 順序が異なる場合、以下のような問題が生じます。 リク エス トを受け付けたが、処理するPodが削除されてしまい、エラーになる。 また、Podが最後のリク エス トを受け付けたはいいが、処理している間にPodが終了されてしまった場合はどうなるでしょうか。このような問題が生じます。 リク エス トの処理が終わっていないにもかかわらずPodが削除されてしまい、エラーになる。 このような状況が生じ、ワークロードに影響を及ぼさないためにもPodがGraceful Shutdownをするように適切に設定しなければいけません。 この問題を解決するには2つの対策が必要です。 以下では、2つの対策・Podのライフサイクルを語る上では非常に重要な用語を用いて解説します。 SIGTERM :終了処理を開始するように命令するシグナル SIGKILL :コンテナを強制的にシャットダウンするように命令するシグナル terminationGracePeriodSeconds : deletionTimeStampが設定されてから何秒でSIGKILLをおくるかの設定 - preStopと、SIGTERMはこの時間内に終わらせることが必要です。 preStop : Podが終了する前に実行される処理 preStop ライフサイクルフックで、 SIGTERM が、サービスから切り離されたあとに送られるように待機させる。 アプリケーション側でSIGTERMシグナルを適切にハンドリングして、リク エス トの処理が終了した後でアプリが終了するように実装する。 terminationGracePeriodSeconds を十分な時間設定して、 SIGKILL が、リク エス トの処理+ SIGTERM ハンドリング処理が終わったのちにPodに送られるよう待機させる。 図で説明すると、以下のような時系列に処理が行われるようにすることで、Graceful Shutdownは達成できます。 Podが必要な数を維持したままドレインされる。 再調整推奨シグナル が出た際などの、ノードがPodをドレインする必要が生じた際、全てのPodを一度に停止してしまったらどのようなことが起こり得るでしょうか。 リク エス トを受け付けるPodがなくなり、サーバーサイドエラーになってしまい、ワークロードに障害が生じます。 このような状況を防ぐために PodDisruptionBudget、通称PDB という機能が存在しています。これは、ノードがPodをドレインする際に、Podが一度に停止できる数を制限できます。 これは、最大停止数 maxUnavailable , 最少起動数 minAvailable を、レプリカ数または割合を指定することで適用できます。 apiVersion : policy/v1beta1 kind : PodDisruptionBudget metadata : name : my-pdb spec : minAvailable : 2 # ここには最小起動数を指定します。数値または割合を指定できます。 #maxUnavailable: 1 またはこのように、最大停止数を指定できます。 selector : matchLabels : app : my-app # ここには、この PDB を適用する Pod のラベルを指定します。 Podが正常であるという状態を適切に定義する。 無事新しいスポット インスタンス にPodがスケジュールされたとしても考慮しなければいけないことがあります。それは、Podがリク エス トを受け付けられるかのヘルスチェックを正確にすることです。 Podの準備ができていないにも関わらず、 トラフィック をPodにルーティングした場合、Podはリク エス トを処理できずにエラーになってしまいます。これでは、ワークロードが中断してしまうため、非常に問題です。 これを解決するのは、Readiness Probeです。 Readiness Probeは、httpGetを用いて特定のパスの状態をチェックするなどして、アプリケーションがリク エス トを正常に処理できる状態になっているかを確認します。これにより、DB接続や時間のかかる起動プロセスが全て完了している、つまり トラフィック を受け付ける準備が整ったときにだけ、 トラフィック がPodにルーティングされます。 Pod Readiness Gate AWS Load Balancer Controllerを利用している場合は、 AWS LoadBalancer Controller Pod Readiness Gate を有効にすることを強くお勧めします。仮に、ローリングアップデートが非常に高速に行われてしまい、PodのLoad Balancerターゲットグループへの登録が遅れた場合はどうなるでしょうか。Load Balancerはバックエンドに、リク エス トを送信できる正常なPodがないと判断し、リク エス トを送信せずにワークロードが中断してしまいます。 しかし、なぜこのようなことが生じてしまうのでしょうか。それは、Load Balancerが正常であると判断するタイミングと、 Kubernetes 内でPodが正常であると判断されるタイミングに差があるからです。 このような問題が生じることをPod Readiness Gate機能を利用することで防ぐことができます。この機能は、Pod Readiness Probeに、「Load Balancerのターゲットグループへ登録されていること」という条件を追加できます。この新たな条件により、Podが正常な状態として判断される前に、そのPodがLoad Balancerのターゲットグループに正しく登録されていることが確認されます。 この結果、ローリングアップデートが行われて新しいPodがデプロイされる際、すべてのPodがLoad Balancerに適切に登録されてからリク エス トを処理し始めることが保証されます。 Podの可用性を考慮したスケジュール 次は、Podの可用性について考えてみましょう。先ほど、ノードがPodをドレインする際に PDB を設定することにより、ワークロードを保つために必要な最低限のPod数を指定しました。 しかし、仮に災害や電源の問題など、何らかで、一部のノードが全く利用できなくなった場合、Pod Disruption Budgets ( PDB ) 設定だけでは、Podの最少数を維持できなくなってしまう可能性があります。 例えば、1つのノードに全てのPodがスケジュールされていたとします。その1つのノードがダウンした場合、サービスは停止することになります。 こうしたシナリオを考慮すると、Podを複数のノード、さらに アベイラビリティ ゾーン(以下AZ)に分散する方針が重要になってきます。これにより、単一のノード・AZで問題が発生した場合でも、他のAZに展開されたPodがワークロードを維持できます。 そのため、 Kubernetes の podAntiAffinity や、 topologySpreadConstraints の設定を使用して、Podの分散を制御するべきです。 podAntiAffinityを利用した場合: apiVersion : apps/v1 kind : Deployment metadata : name : myapp spec : replicas : 8 selector : matchLabels : app : myapp template : metadata : labels : app : myapp spec : containers : - name : myapp image : myapp:1.0.0 affinity : podAntiAffinity : preferredDuringSchedulingIgnoredDuringExecution : - weight : 100 podAffinityTerm : labelSelector : matchLabels : app : myapp topologyKey : topology.kubernetes.io/zone topologySpreadConstraintsを利用した場合: apiVersion : apps/v1 kind : Deployment metadata : name : myapp spec : replicas : 8 selector : matchLabels : app : myapp template : metadata : labels : app : myapp spec : containers : - name : myapp image : myapp:1.0.0 topologySpreadConstraints : - maxSkew : 1 topologyKey : topology.kubernetes.io/zone whenUnsatisfiable : DoNotSchedule labelSelector : matchLabels : app : myapp 上記の YAML 設定は、podAntiAffinityとtopologySpreadConstraintsをそれぞれ利用した場合どのようにAZ間の分散を実現するかの例です。 podAntiAffinityの設定は、同じAZ内に同じアプリケーションのPodが配置されることを避けることを示しています。これは preferredDuringSchedulingIgnoredDuringExecution オプションを使用して、スケジューリング時には 可能な限り 考慮されます。 ここでは、topologyKeyを topology.kubernetes.io/zone に設定しているため、同じAZ内ではなく異なるAZにPodが配置されます。 ※ requiredDuringSchedulingIgnoredDuringExecution は利用しないでください。同じAZでのスケジュールが不可になるため、Podの最大数がAZ数に制限されてしまいます。 2つ目の例では、topologySpreadConstraintsを用いて、Podが均等に分散されるよう制約を加えています。ここでは maxSkew を 1 に設定しており、これは任意の2つのAZ間でのPodの最大の数量差を示します。 つまり、各AZのPodの数が1つだけ異なる場合にのみPodのスケジューリングを許可します。topologyKeyは topology.kubernetes.io/zone に設定され、Podは異なるAZに均等に分散されます。 そして whenUnsatisfiable: DoNotSchedule により、制約を満たすことができない場合には新たなPodのスケジューリングが行われません。 このようにして、Podの配置を細かく制御し、ノードやAZの障害からアプリケーションを保護できます。これらの設定を適切に使用することで、災害や電源の問題など、予期しないイベントが発生した場合でも、ワークロードの中断を回避する、または最小限に抑えることができます。 まとめ 今回、お話しさせていただいた内容は以下です。 スポット インスタンス のリソース確保 マネージド型ノードグループの利用 Mixed Instance Policyの利用 Cluster Autoscalerの利用 ノードグループのAZごとの分散 Priority Expanderの利用 スポット インスタンス の中断に備える PodはGraceful Shutdownされるようになっている。 Podは必要な数を維持したままドレインされる。 Podが正常であるという状態を適切に定義されているか。 Podの可用性を考慮してスケジュールされるようになっている。 これらを実践することで、よりスポット インスタンス を安全に効率的にご利用いただけます。また、スポット インスタンス を利用しない場合でも、後半の「スポット インスタンス の中断に備える」の部分を実践していただくことで、より堅牢にサービスを維持していただくことができます。 おわりに X(クロス) イノベーション 本部 クラウド イノベーション センターでは、新卒・キャリア採用問わず共に働いてくれる仲間を探しています。 本記事で紹介した私の働き方や、 クラウド を中心とした業務にご興味をお持ちの方は、ぜひ採用ページよりご応募ください。 執筆: @taiki_ishii 、レビュー: 柴田 崇夫 (@shibata.takao) / 寺山 輝 (@terayama.akira) ( Shodo で執筆されました )
はじめに 前提 この記事の対象とする読者の方 スポットインスタンスについて 2つのシグナル 2分前中断シグナル 再調整推奨シグナル スポットインスタンスのリソース確保 マネージド型ノードグループの利用 Mixed Instance Policyの利用 Cluster Autoscalerを有効にする。 ノードグループのAZごとの分散 Priority Expander スポットインスタンスの中断に備える ワークロードを停止させないために考慮すること Graceful Shutdownされるようになっている。 Podが必要な数を維持したままドレインされる。 Podが正常であるという状態を適切に定義する。 Pod Readiness Gate Podの可用性を考慮したスケジュール まとめ おわりに はじめに こんにちは!2年目にもかかわらずフレックス制度を存分に活用して、普段8:30 – 16:30で業務をしている クラウド イノベーション センターの石井大樹です。 Amazon EKS(以下EKS)にて、スポット インスタンス は利用されていますか? スポット インスタンス は安いけど、いきなりシャットダウンしちゃうから、使い勝手が悪いんでしょう?など、漠然とした不安から利用をためらっていたりはしないでしょうか。 そのぼんやりとした不安を払しょくし、スポット インスタンス 利用を検討する足がかりになれば、と思い今回は記事にまとめさせていただきました。 前提 この記事の対象とする読者の方 Kubernetes クラスタ をEKSで運用している方 EKS環境でスポット インスタンス を利用しようとしている方 既にEKS環境でスポット インスタンス を利用している方 ワークロードを維持する方法に関心がある方 スポット インスタンス について スポット インスタンス は、通常のオンデマンド インスタンス に比べて最大90%の割引で利用できるオプションです。 しかし、オンデマンド インスタンス と異なり、 AWS 側のリソースキャパシティの都合により、 インスタンス の利用が中断される可能性があります。 2つのシグナル 先ほどスポット インスタンス は、「 AWS 側の都合により中断される可能性がある」とお話しいたしました。しかし、実際には2つのシグナルが発出された後に中断が実行されます。このシグナルを利用することで、スポット インスタンス の中断の影響を最小限に収めることができます。 2分前中断シグナル スポット インスタンス の終了の2分前に通知されるものです。この通知を受け取って2分後に、スポット インスタンス は終了します。 再調整推奨シグナル 対象スポット インスタンス の中断可能性の高まりが検知された場合、2分前シグナルを受け取る前に通知されるシグナルです。このシグナルを受け取ったタイミングで必要な処理が行われるように事前に準備することで、スポット インスタンス を安定して安全に運用できます。 Amazon EC2 AutoScalingグループのCapacity Rebalancingが有効化されている場合、再調整推奨シグナルを受け取ると、 Amazon EC2 AutoScalingは、代替となる新しい インスタンス の起動を試みます。その 新しい インスタンス が起動した後、既存の インスタンス はシャットダウンされます。 なお、EKSノードグループのAutoScalingグループでCapacity Rebalancingを有効化したい場合は、後述するマネージド型ノードグループを利用することで自動的に有効化されます。セルフマネージド型ノードグループで利用をする際は、手動で設定をする必要があります。 また、セルフマネージド型ノードグループにて、再調整推奨シグナルを受け、後述するGraceful Shutdownなどのアクションの実行をするには、 AWS Node Termination Handler などの導入が必要です。セルフマネージド型ノードグループには、 シグナルをキャッチしてノード上のPodの安全な待避を行う仕組みがないためです。 ※注意 この再調整推奨シグナルは2分前中断シグナルよりも前に通知されるということを保証していません。予期せぬリソースの需要が AWS 側で生まれた場合は、2分前中断シグナルと同時に通知を受け取り、必要な対処ができずにワークロードが中断される可能性があります。 再調整推奨シグナルについて スポット インスタンス のリソース確保 せっかくスポット インスタンス の利用を開始したのにも関わらず、スポット インスタンス のリソースが十分に確保できずにワークロードを中断してしまっては元も子もありません。この章では、スポット インスタンス を利用しながら十分なリソースを確保し続ける方法をこちらでご紹介します。 マネージド型ノードグループの利用 EKSでは、マネージド型ノードグループを利用することにより、様々な恩恵を開発者は受けることができます。この恩恵はスポット インスタンス の利用時も例外ではありません。これを利用することにより、EKSでスポット インスタンス を利用する際に AWS がベストプ ラク ティスとして定めた内容を自動的に設定してくれます。 以下が対象の設定内容の一部分です。 配分戦略を capacity-optimized にする。 これにより、中断確率の低いスポット インスタンス が配分されます。 Capacity Rebalancing を有効化。 これにより、 再調整推奨シグナル を受けたときに、EKSは自動的に中断可能性の低いスポットキャパシティプールからスポット インスタンス を起動し、そのノードが準備完了になったら、既存のスポット インスタンス のドレインをしてくれます。 Mixed Instance Policyの利用 複数のEC2 インスタンス タイプをノードグループで利用できるようにすることで、スポット インスタンス を利用できる確率が高まります。利用候補となる インスタンス タイプが増えるためです。しかし、注意することが1点あります。それは、vCPU・メモリが同等の インスタンス タイプであるべきということです。 例えば、 CPU: 2vCPU , メモリ: 4GiB の c5a.large を利用する際は、CPU: 2vCPU , 、メモリ: 4GiB の c5d.large 等と利用されるべきということです。 vCPU・メモリが異なる インスタンス タイプを混在させると、Cluster Autoscalerが正常にリソースの計算ができなくなり、意図しないスケールにつながってしまいます。 Cluster Autoscalerを有効にする。 Cluster Autoscalerは Kubernetes クラスタ を自動的に調整してくれる非常に有効なエコシステムです。このエコシステムは導入のみでも十分な効果を発揮しますが、以下に紹介する機能を利用することでさらなる効果が期待できます。 ※Auto Scalingグループの最大・最少ノード数は尊重されます。そのため、Cluster Autoscalerは最大ノード数以上・最少ノード数以下にはスケールできません。 ノードグループのAZごとの分散 ノードグループを、利用しているリージョンのAZを跨るように作成するようにします。利用候補となる インスタンス が増えるためです。そうすることで、スポット インスタンス を利用できる確率を高めることができます。 Priority Expander しかし、どれだけ対策を練っても、スポット インスタンス が確保できない可能性があります。そんなときは、オンデマンド インスタンス を代わりに確保するようにしましょう。値段は割引がされていない定価で利用することになりますが、ワークロードを中断させないことが最優先です。 Cluster AutoscalerのPriority Expander を利用することで、スポット インスタンス が見つからない際は、オンデマンド インスタンス のノードグループをスケールさせることで不足したリソースを補填できます。 以下が設定例です。 正規表現 で spot に該当するノードグループが優先してスケールされるように設定しています。 apiVersion : v1 kind : ConfigMap metadata : name : cluster-autoscaler-priority-expander namespace : kube-system data : priorities : |- 50 : - .*spot.* 1 : - .*ondemand.* スポット インスタンス の中断に備える ここまでは、できるだけスポット インスタンス を多くの割合で利用する方法をご紹介いたしました。 ご紹介した通り、スポット インスタンス は中断させられてしまうものです。この中断の際にワークロードの実体であるPodに対してなんにも考慮に入れていない場合、ワークロードは中断してしまいます。 ここからはスポット インスタンス が切り替わる際にもワークロードを安全に保つための方法をご紹介します。 ワークロードを停止させないために考慮すること ワークロードを中断させないために考慮する必要があることが4つあります。 PodはGraceful Shutdownされるようになっている。 Podは必要な数を維持したままドレインされる。 Podが正常であるという状態を適切に定義されている。 Podの可用性を考慮してスケジュールされるようになっている。 Graceful Shutdownされるようになっている。 再調整推奨シグナル を受け取り、EKSがノードのドレインを開始して、瞬時にPodが削除されてしまった場合、問題が発生する可能性があります。その問題について理解するには、まずPodの削除が始まると何が起こるかを理解する必要があります。 Podの削除が始まると以下が行われます。 Podの終了処理 ServiceからのPodの切り離し これらが、問題の生じない順序で行われたら良いのですが、 Kubernetes ではこれらは個別のプロセスとして別々に行われるので、それは保証されません。 順序が異なる場合、以下のような問題が生じます。 リク エス トを受け付けたが、処理するPodが削除されてしまい、エラーになる。 また、Podが最後のリク エス トを受け付けたはいいが、処理している間にPodが終了されてしまった場合はどうなるでしょうか。このような問題が生じます。 リク エス トの処理が終わっていないにもかかわらずPodが削除されてしまい、エラーになる。 このような状況が生じ、ワークロードに影響を及ぼさないためにもPodがGraceful Shutdownをするように適切に設定しなければいけません。 この問題を解決するには2つの対策が必要です。 以下では、2つの対策・Podのライフサイクルを語る上では非常に重要な用語を用いて解説します。 SIGTERM :終了処理を開始するように命令するシグナル SIGKILL :コンテナを強制的にシャットダウンするように命令するシグナル terminationGracePeriodSeconds : deletionTimeStampが設定されてから何秒でSIGKILLをおくるかの設定 - preStopと、SIGTERMはこの時間内に終わらせることが必要です。 preStop : Podが終了する前に実行される処理 preStop ライフサイクルフックで、 SIGTERM が、サービスから切り離されたあとに送られるように待機させる。 アプリケーション側でSIGTERMシグナルを適切にハンドリングして、リク エス トの処理が終了した後でアプリが終了するように実装する。 terminationGracePeriodSeconds を十分な時間設定して、 SIGKILL が、リク エス トの処理+ SIGTERM ハンドリング処理が終わったのちにPodに送られるよう待機させる。 図で説明すると、以下のような時系列に処理が行われるようにすることで、Graceful Shutdownは達成できます。 Podが必要な数を維持したままドレインされる。 再調整推奨シグナル が出た際などの、ノードがPodをドレインする必要が生じた際、全てのPodを一度に停止してしまったらどのようなことが起こり得るでしょうか。 リク エス トを受け付けるPodがなくなり、サーバーサイドエラーになってしまい、ワークロードに障害が生じます。 このような状況を防ぐために PodDisruptionBudget、通称PDB という機能が存在しています。これは、ノードがPodをドレインする際に、Podが一度に停止できる数を制限できます。 これは、最大停止数 maxUnavailable , 最少起動数 minAvailable を、レプリカ数または割合を指定することで適用できます。 apiVersion : policy/v1beta1 kind : PodDisruptionBudget metadata : name : my-pdb spec : minAvailable : 2 # ここには最小起動数を指定します。数値または割合を指定できます。 #maxUnavailable: 1 またはこのように、最大停止数を指定できます。 selector : matchLabels : app : my-app # ここには、この PDB を適用する Pod のラベルを指定します。 Podが正常であるという状態を適切に定義する。 無事新しいスポット インスタンス にPodがスケジュールされたとしても考慮しなければいけないことがあります。それは、Podがリク エス トを受け付けられるかのヘルスチェックを正確にすることです。 Podの準備ができていないにも関わらず、 トラフィック をPodにルーティングした場合、Podはリク エス トを処理できずにエラーになってしまいます。これでは、ワークロードが中断してしまうため、非常に問題です。 これを解決するのは、Readiness Probeです。 Readiness Probeは、httpGetを用いて特定のパスの状態をチェックするなどして、アプリケーションがリク エス トを正常に処理できる状態になっているかを確認します。これにより、DB接続や時間のかかる起動プロセスが全て完了している、つまり トラフィック を受け付ける準備が整ったときにだけ、 トラフィック がPodにルーティングされます。 Pod Readiness Gate AWS Load Balancer Controllerを利用している場合は、 AWS LoadBalancer Controller Pod Readiness Gate を有効にすることを強くお勧めします。仮に、ローリングアップデートが非常に高速に行われてしまい、PodのLoad Balancerターゲットグループへの登録が遅れた場合はどうなるでしょうか。Load Balancerはバックエンドに、リク エス トを送信できる正常なPodがないと判断し、リク エス トを送信せずにワークロードが中断してしまいます。 しかし、なぜこのようなことが生じてしまうのでしょうか。それは、Load Balancerが正常であると判断するタイミングと、 Kubernetes 内でPodが正常であると判断されるタイミングに差があるからです。 このような問題が生じることをPod Readiness Gate機能を利用することで防ぐことができます。この機能は、Pod Readiness Probeに、「Load Balancerのターゲットグループへ登録されていること」という条件を追加できます。この新たな条件により、Podが正常な状態として判断される前に、そのPodがLoad Balancerのターゲットグループに正しく登録されていることが確認されます。 この結果、ローリングアップデートが行われて新しいPodがデプロイされる際、すべてのPodがLoad Balancerに適切に登録されてからリク エス トを処理し始めることが保証されます。 Podの可用性を考慮したスケジュール 次は、Podの可用性について考えてみましょう。先ほど、ノードがPodをドレインする際に PDB を設定することにより、ワークロードを保つために必要な最低限のPod数を指定しました。 しかし、仮に災害や電源の問題など、何らかで、一部のノードが全く利用できなくなった場合、Pod Disruption Budgets ( PDB ) 設定だけでは、Podの最少数を維持できなくなってしまう可能性があります。 例えば、1つのノードに全てのPodがスケジュールされていたとします。その1つのノードがダウンした場合、サービスは停止することになります。 こうしたシナリオを考慮すると、Podを複数のノード、さらに アベイラビリティ ゾーン(以下AZ)に分散する方針が重要になってきます。これにより、単一のノード・AZで問題が発生した場合でも、他のAZに展開されたPodがワークロードを維持できます。 そのため、 Kubernetes の podAntiAffinity や、 topologySpreadConstraints の設定を使用して、Podの分散を制御するべきです。 podAntiAffinityを利用した場合: apiVersion : apps/v1 kind : Deployment metadata : name : myapp spec : replicas : 8 selector : matchLabels : app : myapp template : metadata : labels : app : myapp spec : containers : - name : myapp image : myapp:1.0.0 affinity : podAntiAffinity : preferredDuringSchedulingIgnoredDuringExecution : - weight : 100 podAffinityTerm : labelSelector : matchLabels : app : myapp topologyKey : topology.kubernetes.io/zone topologySpreadConstraintsを利用した場合: apiVersion : apps/v1 kind : Deployment metadata : name : myapp spec : replicas : 8 selector : matchLabels : app : myapp template : metadata : labels : app : myapp spec : containers : - name : myapp image : myapp:1.0.0 topologySpreadConstraints : - maxSkew : 1 topologyKey : topology.kubernetes.io/zone whenUnsatisfiable : DoNotSchedule labelSelector : matchLabels : app : myapp 上記の YAML 設定は、podAntiAffinityとtopologySpreadConstraintsをそれぞれ利用した場合どのようにAZ間の分散を実現するかの例です。 podAntiAffinityの設定は、同じAZ内に同じアプリケーションのPodが配置されることを避けることを示しています。これは preferredDuringSchedulingIgnoredDuringExecution オプションを使用して、スケジューリング時には 可能な限り 考慮されます。 ここでは、topologyKeyを topology.kubernetes.io/zone に設定しているため、同じAZ内ではなく異なるAZにPodが配置されます。 ※ requiredDuringSchedulingIgnoredDuringExecution は利用しないでください。同じAZでのスケジュールが不可になるため、Podの最大数がAZ数に制限されてしまいます。 2つ目の例では、topologySpreadConstraintsを用いて、Podが均等に分散されるよう制約を加えています。ここでは maxSkew を 1 に設定しており、これは任意の2つのAZ間でのPodの最大の数量差を示します。 つまり、各AZのPodの数が1つだけ異なる場合にのみPodのスケジューリングを許可します。topologyKeyは topology.kubernetes.io/zone に設定され、Podは異なるAZに均等に分散されます。 そして whenUnsatisfiable: DoNotSchedule により、制約を満たすことができない場合には新たなPodのスケジューリングが行われません。 このようにして、Podの配置を細かく制御し、ノードやAZの障害からアプリケーションを保護できます。これらの設定を適切に使用することで、災害や電源の問題など、予期しないイベントが発生した場合でも、ワークロードの中断を回避する、または最小限に抑えることができます。 まとめ 今回、お話しさせていただいた内容は以下です。 スポット インスタンス のリソース確保 マネージド型ノードグループの利用 Mixed Instance Policyの利用 Cluster Autoscalerの利用 ノードグループのAZごとの分散 Priority Expanderの利用 スポット インスタンス の中断に備える PodはGraceful Shutdownされるようになっている。 Podは必要な数を維持したままドレインされる。 Podが正常であるという状態を適切に定義されているか。 Podの可用性を考慮してスケジュールされるようになっている。 これらを実践することで、よりスポット インスタンス を安全に効率的にご利用いただけます。また、スポット インスタンス を利用しない場合でも、後半の「スポット インスタンス の中断に備える」の部分を実践していただくことで、より堅牢にサービスを維持していただくことができます。 おわりに X(クロス) イノベーション 本部 クラウド イノベーション センターでは、新卒・キャリア採用問わず共に働いてくれる仲間を探しています。 本記事で紹介した私の働き方や、 クラウド を中心とした業務にご興味をお持ちの方は、ぜひ採用ページよりご応募ください。 執筆: @taiki_ishii 、レビュー: 柴田 崇夫 (@shibata.takao) / 寺山 輝 (@terayama.akira) ( Shodo で執筆されました )
金融ソリューション事業部の石沢です。ふだんは所属部門の様々なプロジェクトの支援をしています。今回の記事では、当社でやっている 「伴走型研修」 を受講者の立場で紹介します。資格試験については会社の補助で制限なく受験できるのですが、それでも勉強するのは大変ですよね。 くじけずにやりきる ために今回は伴走型研修というものを利用して、無事に資格合格したというお話です。 受験のきっかけ 今回挑戦したのはタイトルにある通り、 AWS Certified Solutions Architect - Associate(以下SAA)です。さすがに説明は不要だと思いますが、詳細は AWS さんのサイトをご確認ください。 AWS Certified Solutions Architect – Associate 認定 自分は、より初心者向けの AWS Certified Cloud Practitionerという資格を3年前に取得していました。この資格の有効期限が切れるので、再受験して維持するのか、より上位の資格にチャレンジを悩んでいたところでした。悩んだポイントは 実務としての AWS を利用したシステム構築経験がない SAAは中級向けの資格であり、難易度はそこそこ高いらしい 割と忙しいので受験を先延ばしにしたり、挫折することになりそう です。組織的にはそれなりの職級にいるので、別に上司から資格を取れと言われるわけでもありません。どうしよっかなー、失敗したら恥ずかしいしパスしようかなー……。 と悩んでいたところに、伴走型研修の案内が来たのでした。 伴走型研修とは もともと社では AWS 認定資格受験希望者に対して学習コンテンツや学習用環境、資格受験費用の全額補助が提供されているのですが、さらに昨年から任意参加型の「伴走型研修」も利用できるようになっていました。 伴走型研修では、以下のサポートを受けることができます(2023年現在の情報) キックオフウェビナーでの、学習の進め方についての全体ガイダンス 講師による個別面談(スキルや状況に応じた学習計画についてアド バイス してもらえる) 毎週30分のミニ勉強会(試験問題解説や情報共有など) 学習中につまづいたポイントや不明点に関する相談や質疑応答など 私はSAAコースを選択しましたが、 AWS Certified Solutions Architect - Professionalコースも選択可能です。心強い! 実際にやったこと キックオフウェビナー 資格の概要に始まり、学習方法についてひととおり説明していただき助かりました。インターネットにたくさんの情報が公開されているのですが、書いてある内容はバラバラなので、まとめて確認し質疑応答できるのはかなり助かる! 個別面談 キックオフ後、講師による個別の1on1で学習スケジュールと資格受験日程を決めました。受講の目的や AWS 経験などをアド バイス してもらえます。私の場合はだいたい基礎知識学習1カ月+問題集などを利用した試験対策1カ月の2カ月コースが良さそうということでスケジュールを組みました。また挫折防止のために、試験を予約したほうが良いとのこと。えーっと思いながらも従順に2カ月後の試験予約もしてしまいます。デッドラインが決まって身が引き締まります。 なお、この伴走型研修では学習自体は各自が自由に進めることになります(座学の講義があるわけではない)。おすすめコンテンツなどを紹介いただきながら、書籍、 AWS が公開しているコンテンツやUdemy講座を中心のカリキュラムを自分で選択することになります。私自身は学習期間にあまりまとまった時間が取れない見込みだったので、基礎知識の学習についても書籍やドキュメントなどの読み込みではなく、Udemyの講座でキャッチアップすることにしました(スキマ時間を活用するため)。 毎週30分のミニ勉強会 技術資格の勉強は、わりと孤独なものになりがちです。ですが、伴走型研修では毎週30分のオンライン勉強会があるので孤立感が軽減されていて良かったです。業務時間内でも30分という時間なので調整しやすく、欠席をしても録画でキャッチアップすることができるようになっています。勉強会では講師の出す例題を皆で解いたり、理解しにくい点について解説を受けたりしました。 受講者チャネルでのフォローアップ 上記以外には、受講者の参加するコミュニケーションチャンネル( Microsoft Teamsの専用Teamでのチャット)でのフォローアップがあります。学習コンテンツでわからなかった点や、模擬試験集の解説で腑に落ちない点などについて質問をできます。しっかり活用しました。 自習 伴走型研修で伴走してもらいながら、実際に自分でやった事は以下のとおりです。いずれも個人の費用負担はありません。 ハンズオン用の AWS 学習環境の取得(社内で簡単なフォームに入力するだけ。各種ガード設定付きです) いくつかの AWS 提供ドキュメントの通読(読んでおくと良いとアド バイス されたもの) Udemy:【ベストセラー完全日本語化】AWS 認定ソリューションアーキテクト アソシエイト SAA-C03 対応 2022 最新版 書籍での学習を断念し、こちらの講座をUdemy for Business枠で受講。また講座内のハンズオンは極力自分でも手を動かしています。27時間を少しスピードを上げて視聴しています Udemy:【SAA-C03版】AWS 認定ソリューションアーキテクト アソシエイト模擬試験問題集(6回分390問) 試験対策として有名なコースですね。当初想像していた問題よりこちらの模擬試験のほうが難しく感じ、また最初はまったく正解できず焦りましたが、復習をしっかりやって知識を補完していきました 試験受験 最後は試験の受験です。自宅だと集中しにくいのでテストセンターで受験し、無事に1回で合格できました。 よかったこと 伴走型研修に参加して一番良かったのは、途中でくじけずに完走して予定通りに資格取得が完了したことです。伴走型研修を利用せずに個人学習のみでチャレンジしていたら、おそらくもっと時間がかかったと思います(ズルズルと試験日を延ばしていたはず)。もしかしたら、途中であきらめてしまったかもしれません。 なお、毎週の勉強会で講師から 「進捗どうですか、質問はいつでもどうぞ」 と言われていたのが自分にとっては先延ばし防止効果がありました。別に進捗報告の必要はなく単なる声掛けだったのですが、勉強が出来ていない週は「ウッ」となって、あわててやっていました。 加えて、世の中にあふれている資格情報に惑わされなかったという点も自分にとっては大きかったです。試験日が近づくにつれて、つい 銀の弾丸 を求めてネットをさまよってしまい「〇〇をやればよい」とか「〇〇だけでは不十分だ」といったブログ記事を見てしまい不安になってしまうのですが、信頼できる講師と話ができるので不安もだいぶ緩和されたと思います。 まとめ 伴走型研修というものを使って、 AWS 実務経験なしでもくじけずに資格取得が出来て良かった という話でした。個人の学習で詰まっている人は、伴走者を見つけると良いかもしれません。 もちろん当社に入社していただければ、ここで紹介した伴走型研修そのものに参加することもできます。興味があればぜひ以下のページもご覧ください! 私たちは一緒に働いてくれる仲間を募集しています! ISID 募集職種一覧 執筆: Ishizawa Kento (@kent) 、レビュー: @sato.taichi ( Shodo で執筆されました )
金融ソリューション事業部の石沢です。ふだんは所属部門の様々なプロジェクトの支援をしています。今回の記事では、当社でやっている 「伴走型研修」 を受講者の立場で紹介します。資格試験については会社の補助で制限なく受験できるのですが、それでも勉強するのは大変ですよね。 くじけずにやりきる ために今回は伴走型研修というものを利用して、無事に資格合格したというお話です。 受験のきっかけ 今回挑戦したのはタイトルにある通り、 AWS Certified Solutions Architect - Associate(以下SAA)です。さすがに説明は不要だと思いますが、詳細は AWS さんのサイトをご確認ください。 AWS Certified Solutions Architect – Associate 認定 自分は、より初心者向けの AWS Certified Cloud Practitionerという資格を3年前に取得していました。この資格の有効期限が切れるので、再受験して維持するのか、より上位の資格にチャレンジを悩んでいたところでした。悩んだポイントは 実務としての AWS を利用したシステム構築経験がない SAAは中級向けの資格であり、難易度はそこそこ高いらしい 割と忙しいので受験を先延ばしにしたり、挫折することになりそう です。組織的にはそれなりの職級にいるので、別に上司から資格を取れと言われるわけでもありません。どうしよっかなー、失敗したら恥ずかしいしパスしようかなー……。 と悩んでいたところに、伴走型研修の案内が来たのでした。 伴走型研修とは もともと社では AWS 認定資格受験希望者に対して学習コンテンツや学習用環境、資格受験費用の全額補助が提供されているのですが、さらに昨年から任意参加型の「伴走型研修」も利用できるようになっていました。 伴走型研修では、以下のサポートを受けることができます(2023年現在の情報) キックオフウェビナーでの、学習の進め方についての全体ガイダンス 講師による個別面談(スキルや状況に応じた学習計画についてアド バイス してもらえる) 毎週30分のミニ勉強会(試験問題解説や情報共有など) 学習中につまづいたポイントや不明点に関する相談や質疑応答など 私はSAAコースを選択しましたが、 AWS Certified Solutions Architect - Professionalコースも選択可能です。心強い! 実際にやったこと キックオフウェビナー 資格の概要に始まり、学習方法についてひととおり説明していただき助かりました。インターネットにたくさんの情報が公開されているのですが、書いてある内容はバラバラなので、まとめて確認し質疑応答できるのはかなり助かる! 個別面談 キックオフ後、講師による個別の1on1で学習スケジュールと資格受験日程を決めました。受講の目的や AWS 経験などをアド バイス してもらえます。私の場合はだいたい基礎知識学習1カ月+問題集などを利用した試験対策1カ月の2カ月コースが良さそうということでスケジュールを組みました。また挫折防止のために、試験を予約したほうが良いとのこと。えーっと思いながらも従順に2カ月後の試験予約もしてしまいます。デッドラインが決まって身が引き締まります。 なお、この伴走型研修では学習自体は各自が自由に進めることになります(座学の講義があるわけではない)。おすすめコンテンツなどを紹介いただきながら、書籍、 AWS が公開しているコンテンツやUdemy講座を中心のカリキュラムを自分で選択することになります。私自身は学習期間にあまりまとまった時間が取れない見込みだったので、基礎知識の学習についても書籍やドキュメントなどの読み込みではなく、Udemyの講座でキャッチアップすることにしました(スキマ時間を活用するため)。 毎週30分のミニ勉強会 技術資格の勉強は、わりと孤独なものになりがちです。ですが、伴走型研修では毎週30分のオンライン勉強会があるので孤立感が軽減されていて良かったです。業務時間内でも30分という時間なので調整しやすく、欠席をしても録画でキャッチアップすることができるようになっています。勉強会では講師の出す例題を皆で解いたり、理解しにくい点について解説を受けたりしました。 受講者チャネルでのフォローアップ 上記以外には、受講者の参加するコミュニケーションチャンネル( Microsoft Teamsの専用Teamでのチャット)でのフォローアップがあります。学習コンテンツでわからなかった点や、模擬試験集の解説で腑に落ちない点などについて質問をできます。しっかり活用しました。 自習 伴走型研修で伴走してもらいながら、実際に自分でやった事は以下のとおりです。いずれも個人の費用負担はありません。 ハンズオン用の AWS 学習環境の取得(社内で簡単なフォームに入力するだけ。各種ガード設定付きです) いくつかの AWS 提供ドキュメントの通読(読んでおくと良いとアド バイス されたもの) Udemy:【ベストセラー完全日本語化】AWS 認定ソリューションアーキテクト アソシエイト SAA-C03 対応 2022 最新版 書籍での学習を断念し、こちらの講座をUdemy for Business枠で受講。また講座内のハンズオンは極力自分でも手を動かしています。27時間を少しスピードを上げて視聴しています Udemy:【SAA-C03版】AWS 認定ソリューションアーキテクト アソシエイト模擬試験問題集(6回分390問) 試験対策として有名なコースですね。当初想像していた問題よりこちらの模擬試験のほうが難しく感じ、また最初はまったく正解できず焦りましたが、復習をしっかりやって知識を補完していきました 試験受験 最後は試験の受験です。自宅だと集中しにくいのでテストセンターで受験し、無事に1回で合格できました。 よかったこと 伴走型研修に参加して一番良かったのは、途中でくじけずに完走して予定通りに資格取得が完了したことです。伴走型研修を利用せずに個人学習のみでチャレンジしていたら、おそらくもっと時間がかかったと思います(ズルズルと試験日を延ばしていたはず)。もしかしたら、途中であきらめてしまったかもしれません。 なお、毎週の勉強会で講師から 「進捗どうですか、質問はいつでもどうぞ」 と言われていたのが自分にとっては先延ばし防止効果がありました。別に進捗報告の必要はなく単なる声掛けだったのですが、勉強が出来ていない週は「ウッ」となって、あわててやっていました。 加えて、世の中にあふれている資格情報に惑わされなかったという点も自分にとっては大きかったです。試験日が近づくにつれて、つい 銀の弾丸 を求めてネットをさまよってしまい「〇〇をやればよい」とか「〇〇だけでは不十分だ」といったブログ記事を見てしまい不安になってしまうのですが、信頼できる講師と話ができるので不安もだいぶ緩和されたと思います。 まとめ 伴走型研修というものを使って、 AWS 実務経験なしでもくじけずに資格取得が出来て良かった という話でした。個人の学習で詰まっている人は、伴走者を見つけると良いかもしれません。 もちろん当社に入社していただければ、ここで紹介した伴走型研修そのものに参加することもできます。興味があればぜひ以下のページもご覧ください! 私たちは一緒に働いてくれる仲間を募集しています! ISID 募集職種一覧 執筆: Ishizawa Kento (@kent) 、レビュー: @sato.taichi ( Shodo で執筆されました )
ISID X(クロス) イノベーション 本部 の池田です。 2023年2月にGAした Azure Load Testing で利用する機会がありましたので使い方のご紹介です。 目次 目次 Azure Load Testingとは Azure Load Testingの価格 試験環境の概要 Azure Load Testing リソースの作成 テストシナリオの作成 負荷テストの実施 負荷テストの実行結果 まとめ Azure Load Testingとは Azure Load Testingは、Azureの クラウド インフラスト ラク チャを使用して、アプリケーションの負荷テストを実行するためのサービスです。使い慣れた JMeter スクリプト で作成したテストシナリオを クラウド 上で実行できます。 自分で 負荷試験 をしようとすると、試験用の端末を複数用意したり、ネットワーク関連のOSパラメータの変更したり考慮すべき点が多いです。また、社内から実行すると ファイアウォール で制限されたりネットワーク環境も考慮する必要があります。 Azure Load Testingを利用することで、こうした面倒な環境作成はAzureにお任せして、利用者は負荷テストシナリオ作成と試験結果の分析に注力できます。 azure.microsoft.com Azure Load Testingの価格 基本的に、仮想ユーザー時間という概念での課金となっています。 JMeter スクリプト 内の仮想ユーザー(並列スレッド)数と、テストエンジン インスタンス の数によって算出されます。 仮想ユーザーの合計数 = ( JMX ファイル内の仮想ユーザー数) * (テスト エンジン インスタンス の数) インスタンス 数だけでなく JMeter スクリプト 内で指定する並列実行数も課金額に影響しますので注意しましょう。 また、テストを実行していなくても少額の課金があります。 Azure Load Testing の価格 試験環境の概要 今回使用するテスト環境の構成は以下のとおりです。App Service 既定のWebサイトをテスト対象としてAzure Load TestingからHTTPリク エス トを連続送信します。その上でAzureリソースの負荷状況をAzure Load Testingの ダッシュ ボードで観測します。 Azure Load Testing リソースの作成 以降では実際にテストを作成します。 Azure Portal からリソースを作成します。 執筆時点で東西日本リージョンではまだ提供されていませんでしたので東アジアリージョンを利用します。 リソースが作成されました。 テストシナリオの作成 Azure Load Testing では「クイックテストを作成する」 および 「 JMeter スクリプト のアップロード」の2種類の方法でテストが作成できます。 クイックテストでは、特定のURLに対しシンプルなGETリク エス トを繰り返す JMeter スクリプト を作成できます。 しかし、認証を含んだシナリオやPOSTリク エス トを含んだシナリオは Azure Load Testing 上で作成できません。様々なケースのシナリオをテストしたい場合には外部で JMeter スクリプト を作成してアップロードする必要があります。 今回のシナリオではクイックテストでも十分ですが、実務を模して スクリプト を取り込む手順でテスト作成します。 JMeter の入手 スクリプト 作成に使用する JMeter はサイトからダウンロードしましょう。Azure Load Testing以外の クラウド 負荷試験 ツールも JMeter スクリプト を取り込んで実行するツールが多いです。初版が2001年のソフトウェアですが JMeter はまだまだ現役です。 jmeter.apache.org スクリプト の作成 ダウンロードした JMeter を使ってローカルでテストを作成します。どこか懐かしい気持ちに浸りながらテストを作成します。スレッド数の設定はコストに影響する部分ですので、過剰に大きくしないよう注意しましょう。 負荷テストの実施 「 JMeter スクリプト のアップロード」を選択しテストを作成します。 まずは、テスト名、説明を入力します。 先ほど作成した JMeter スクリプト をアップロードします。 スクリプト 内で使用するパラメータをこちらで指定できます。 機密性の高いパラメータはKeyVaultから持ってくることも可能です。 スクリプト を実行するエンジンの インスタンス 数を指定します。 テスト抽出条件の設定です。 テスト抽出条件は、レスポンスタイムやエラー率、 スループット の しきい値 を設定し、 しきい値 を超えるとテスト不合格となりテスト全体が終了します。 自動停止テストは、構成エラーによる異常を検知しエンジンを停止する目的で利用します。エラーの割合はエンジン単位で判定されエンジン単位で個別に終了されます。テスト全体としては継続されますので利用方法に注意しましょう。 テスト結果画面でメトリックを表示する インスタンス を選択します。 インスタンス を選択すると既定で設定されているメトリックが試験結果 ダッシュ ボードに表示されます。 ダッシュ ボードに表示する インスタンス やメトリックはテスト後に追加削除が可能です。 メジャーどころのAzureリソースはサポートされていますが、執筆時点ではApplication Gateway がサポートされていませんでした。こちらは今後の追加を期待したいところです。 参考: サポートされている Azure リソースの種類 設定した内容でテストを作成します。 負荷テストの実行結果 実行画面に遷移すると ダッシュ ボードで各種状態が確認できます。実行中に試験結果が随時プロットされて表示されます。クライアント側の試験状況 と サーバー側の負荷状況 が一画面で確認でき大変便利です。 クライアント側メトリック こちらはクライアント側のメトリック情報になります。 各エンジン インスタンス から収集した内容を集計した結果が表示されます。 サーバー側メトリック Azureリソースの各種メトリックが表示されます。テスト後に表示メトリックを追加することもできます。 各種メトリックを一覧表示してどこが ボトルネック となっているのか把握できます。 ただし、項目による分割 や 条件によるフィルタ などの詳細分析はできません。そうした分析は通常のメトリック画面から別途分析しましょう。 エンジン状態 JMeter スクリプト を実行しているエンジン個別の状態も表示できます。 まとめ Azure Load Testingは、 クラウド 上での負荷テストを簡単かつ効果的に行うための強力なツールです。従来の手法に比べて手軽さや柔軟性があり、リアルタイムでの結果の監視も可能です。アプリケーションのパフォーマンスを向上させたい場合や、スケーラビリティの評価を行いたい場合には、Azure Load Testingを検討してみてはいかがでしょうか。 私たちは同じチームで働いてくれる仲間を探しています。 クラウド アーキテクトの業務に興味がある方のご応募をお待ちしています。 クラウドアーキテクト 執筆: @ikeda.jun 、レビュー: @nakamura.toshihiro ( Shodo で執筆されました )
ISID X(クロス) イノベーション 本部 の池田です。 2023年2月にGAした Azure Load Testing で利用する機会がありましたので使い方のご紹介です。 目次 目次 Azure Load Testingとは Azure Load Testingの価格 試験環境の概要 Azure Load Testing リソースの作成 テストシナリオの作成 負荷テストの実施 負荷テストの実行結果 まとめ Azure Load Testingとは Azure Load Testingは、Azureの クラウド インフラスト ラク チャを使用して、アプリケーションの負荷テストを実行するためのサービスです。使い慣れた JMeter スクリプト で作成したテストシナリオを クラウド 上で実行できます。 自分で 負荷試験 をしようとすると、試験用の端末を複数用意したり、ネットワーク関連のOSパラメータの変更したり考慮すべき点が多いです。また、社内から実行すると ファイアウォール で制限されたりネットワーク環境も考慮する必要があります。 Azure Load Testingを利用することで、こうした面倒な環境作成はAzureにお任せして、利用者は負荷テストシナリオ作成と試験結果の分析に注力できます。 azure.microsoft.com Azure Load Testingの価格 基本的に、仮想ユーザー時間という概念での課金となっています。 JMeter スクリプト 内の仮想ユーザー(並列スレッド)数と、テストエンジン インスタンス の数によって算出されます。 仮想ユーザーの合計数 = ( JMX ファイル内の仮想ユーザー数) * (テスト エンジン インスタンス の数) インスタンス 数だけでなく JMeter スクリプト 内で指定する並列実行数も課金額に影響しますので注意しましょう。 また、テストを実行していなくても少額の課金があります。 Azure Load Testing の価格 試験環境の概要 今回使用するテスト環境の構成は以下のとおりです。App Service 既定のWebサイトをテスト対象としてAzure Load TestingからHTTPリク エス トを連続送信します。その上でAzureリソースの負荷状況をAzure Load Testingの ダッシュ ボードで観測します。 Azure Load Testing リソースの作成 以降では実際にテストを作成します。 Azure Portal からリソースを作成します。 執筆時点で東西日本リージョンではまだ提供されていませんでしたので東アジアリージョンを利用します。 リソースが作成されました。 テストシナリオの作成 Azure Load Testing では「クイックテストを作成する」 および 「 JMeter スクリプト のアップロード」の2種類の方法でテストが作成できます。 クイックテストでは、特定のURLに対しシンプルなGETリク エス トを繰り返す JMeter スクリプト を作成できます。 しかし、認証を含んだシナリオやPOSTリク エス トを含んだシナリオは Azure Load Testing 上で作成できません。様々なケースのシナリオをテストしたい場合には外部で JMeter スクリプト を作成してアップロードする必要があります。 今回のシナリオではクイックテストでも十分ですが、実務を模して スクリプト を取り込む手順でテスト作成します。 JMeter の入手 スクリプト 作成に使用する JMeter はサイトからダウンロードしましょう。Azure Load Testing以外の クラウド 負荷試験 ツールも JMeter スクリプト を取り込んで実行するツールが多いです。初版が2001年のソフトウェアですが JMeter はまだまだ現役です。 jmeter.apache.org スクリプト の作成 ダウンロードした JMeter を使ってローカルでテストを作成します。どこか懐かしい気持ちに浸りながらテストを作成します。スレッド数の設定はコストに影響する部分ですので、過剰に大きくしないよう注意しましょう。 負荷テストの実施 「 JMeter スクリプト のアップロード」を選択しテストを作成します。 まずは、テスト名、説明を入力します。 先ほど作成した JMeter スクリプト をアップロードします。 スクリプト 内で使用するパラメータをこちらで指定できます。 機密性の高いパラメータはKeyVaultから持ってくることも可能です。 スクリプト を実行するエンジンの インスタンス 数を指定します。 テスト抽出条件の設定です。 テスト抽出条件は、レスポンスタイムやエラー率、 スループット の しきい値 を設定し、 しきい値 を超えるとテスト不合格となりテスト全体が終了します。 自動停止テストは、構成エラーによる異常を検知しエンジンを停止する目的で利用します。エラーの割合はエンジン単位で判定されエンジン単位で個別に終了されます。テスト全体としては継続されますので利用方法に注意しましょう。 テスト結果画面でメトリックを表示する インスタンス を選択します。 インスタンス を選択すると既定で設定されているメトリックが試験結果 ダッシュ ボードに表示されます。 ダッシュ ボードに表示する インスタンス やメトリックはテスト後に追加削除が可能です。 メジャーどころのAzureリソースはサポートされていますが、執筆時点ではApplication Gateway がサポートされていませんでした。こちらは今後の追加を期待したいところです。 参考: サポートされている Azure リソースの種類 設定した内容でテストを作成します。 負荷テストの実行結果 実行画面に遷移すると ダッシュ ボードで各種状態が確認できます。実行中に試験結果が随時プロットされて表示されます。クライアント側の試験状況 と サーバー側の負荷状況 が一画面で確認でき大変便利です。 クライアント側メトリック こちらはクライアント側のメトリック情報になります。 各エンジン インスタンス から収集した内容を集計した結果が表示されます。 サーバー側メトリック Azureリソースの各種メトリックが表示されます。テスト後に表示メトリックを追加することもできます。 各種メトリックを一覧表示してどこが ボトルネック となっているのか把握できます。 ただし、項目による分割 や 条件によるフィルタ などの詳細分析はできません。そうした分析は通常のメトリック画面から別途分析しましょう。 エンジン状態 JMeter スクリプト を実行しているエンジン個別の状態も表示できます。 まとめ Azure Load Testingは、 クラウド 上での負荷テストを簡単かつ効果的に行うための強力なツールです。従来の手法に比べて手軽さや柔軟性があり、リアルタイムでの結果の監視も可能です。アプリケーションのパフォーマンスを向上させたい場合や、スケーラビリティの評価を行いたい場合には、Azure Load Testingを検討してみてはいかがでしょうか。 私たちは同じチームで働いてくれる仲間を探しています。 クラウド アーキテクトの業務に興味がある方のご応募をお待ちしています。 クラウドアーキテクト 執筆: @ikeda.jun 、レビュー: @nakamura.toshihiro ( Shodo で執筆されました )
はじめに こんにちは。X(クロス) イノベーション 本部 ソフトウェアデザインセンター の山下です。 みなさん GitHub Codespaces( https://github.co.jp/features/codespaces ) をご存知ですか? 昨年一般提供された GitHub で利用できる 統合開発環境 ( IDE )です。 GitHub Codespacesは、ブラウザ上で動作する開発環境となります。 GitHub から起動することができて、ブラウザが動く環境であれば、追加でソフトウェアのインストールを必要とせず、いつでもどこでも開発作業が可能です。 この記事では、その特徴、使い方、そして便利な点について解説します。特にチームの開発で開発環境を統一したい場合や素早く開発環境を用意したい場合には GitHub Codespaces を検討しても良いかもしれません。 GitHub Codespacesの特徴 GitHub Codespacesは、 クラウド 上に開発環境を構築し、 クラウド 上で完結する開発環境です。開発者はデ バイス や場所に依存せず、どこからでもコードの編集や実行が可能になります。従来の開発環境では、各個人のPCに環境を構築する必要がありましたが、Codespacesを使用するとその必要がありません。 Visual Studio Code と完全に統合されているため、その強力な機能を利用できます。コードの補完、 シンタックス ハイライト、 リファクタリング 、 デバッグ といった機能はもちろんのこと、 拡張機能 をインストールしてカスタマイズすることも可能です。 この記事では GitHub Codespacesの概要を紹介しますが、詳細は公式のドキュメントも参照して下さい。 https://docs.github.com/ja/codespaces GitHub Codespacesの利用方法 GitHub Codespacesの利用はとても簡単です。 GitHub の リポジトリ ページにアクセスし、"Code"ボタンの下にある"Open with Codespaces"を選択するだけでCodespaceを作成できます。 Codespaceが作成されると、その リポジトリ 専用に GitHub が提供している クラウド 上に開発環境が構築され、アクセスすると Visual Studio Code がブラウザで立ち上がります。 しかも リポジトリ の ソースコード は既に git clone されていて、すぐにコードの編集や デバッグ を開始できます。また起動した Visual Studio Code はローカルで使用しているのとほぼ同じ機能が実現されています。普段から Visual Studio Code に慣れている開発者であれば、普段通りコードの編集や デバッグ が可能です。もちろん、コード補完、 シンタックス ハイライト、 リファクタリング 、 拡張機能 も利用できます。 GitHub Codespacesの便利な点 ここでは、自分が GitHub Codespacesを使っていて便利だと思った点をいくつか紹介していこうと思います。 Dev Containerを使った環境のカスタマイズ GitHub Codespacesでは、Dev Containerを使った開発環境のカスタマイズを行うことも可能です。 .devcontainer/devcontainer.json を作成して、Dev Container環境を作成することが出来ます。この設定ファイルでは、使用する基本イメージの指定、必要なツールのインストール、VisualStudio Codeの設定、ポートの開放などを定義できます。 先日、このテックブログにもDev Containerを使った Python の開発環境を構築する記事が公開されています。Dev Containerの設定についてはそちらが参考になるかと思います。 Dev Containerを使ってステップバイステップで作るPythonアプリケーション開発環境 Dev Container用の設定ファイルは、 リポジトリ に含めて共有することで、チーム全体で統一した開発環境を簡単に構築できます。 Dev Containerの設定については、 GitHub Codespacesにも説明があるので確認してみてください。 https://docs.github.com/ja/codespaces/setting-up-your-project-for-codespaces/adding-a-dev-container-configuration/introduction-to-dev-containers GitHub Codespacesでの 拡張機能 の扱い GitHub Codespaces上では起動した環境上でMarketplace から 拡張機能 のインストールが可能です。 もし普段、 Visual Studio Code の設定の同期機能を利用している場合であれば、 GitHub Codespacesとも設定を同期させることも可能です。VisualStudio Codeの設定の保存に GitHub を利用している場合、起動した GitHub CodeSpacesに同期している設定が自動的に読み込まれます。この機能とDev Containerを組み合わせることで柔軟に開発環境を構築することが可能となります。Dev Containerではチームで使う 拡張機能 を共通でインストールして、 Visual Studio Code の同期機能で個人の 拡張機能 をインストールするというような運用が可能となります。 GitHub Codespacesのパーソナライズの項目に説明があるので確認してみてください。 https://docs.github.com/ja/enterprise-cloud@latest/codespaces/customizing-your-codespace/personalizing-github-codespaces-for-your-account GitHub との統合 GitHub との統合も大きな特徴の一つです。起動した GitHub Codespacesの環境では起動した状態で git clone が実行されている状態であることは紹介しました。さらに、 GitHub への認証が完了している状態で起動しているので、 リポジトリ へのコードの git push といった操作を行なう際に追加の設定作業は不要です。起動して、コードを修正して、テストして、すぐにpushするという作業に集中できます。 GItHub 用の 拡張機能 もインストールされているため、Pull Requestの作成、マージ、コードレビュー、イシューの管理も行えます。 クラウド 上に開発環境があることのメリット ここまでで、 GitHub Codespacesの便利な機能を簡単に紹介しました。この他に GitHub Codespacesが クラウド 環境に開発環境が構築されることによるメリットもあります。 開発環境の構築が高速 まず一つ目として、新規プロジェクトを開始する際に、従来のローカル環境と比べて環境設定の時間を大幅に短縮できる点が挙げられます。新たに必要なツールやライブラリをインストールする手間が省け、すぐに開発に取りかかることができます。 開発で利用するマシンが自由 二つ目として、異なるOSやデ バイス での開発が容易になる点です。たとえば、普段は Mac を使って開発している人でもブラウザさえ起動できれば同一の環境がすぐ手に入ります。 開発環境の統一の実現 そして、最も大きな利点として、チームの効率化を挙げることができます。全てのチームメンバーが同じ開発環境を簡単に共有できるため、環境差による問題が大幅に減少します。また、Codespaceはブラウザ上で動作するため、どのデ バイス からでもアクセスできます。これにより、場所にとらわれずに作業が可能となります。 GitHub Codespacesの注意点と制限 使用料金はリソースの使用量によります。無駄な費用を避けるためには、使用しないCodespacesは閉じるなど、適切な管理が求められます。Codespacesは開発環境を起動している時間と利用しているストレージの料金の2種類が請求されます。使っていない環境は自動的に削除されるので、無駄な起動時間の請求はあまり発生しないと思います。しかし、ストレージの方は自動的に削除されないため注意が必要となります。 GitHub Codespacesの利用料金は以下のページを参照してください。 https://docs.github.com/ja/billing/managing-billing-for-github-codespaces/about-billing-for-github-codespaces また、一部のハードウェア依存の開発や、特定の開発を GitHub Codespacesでは行なうことが出来ません。例えば、高度な3Dグラフィックスを必要とする開発作業や、特別なハードウェアに依存した開発作業は、現在のところCodespacesではサポートされていません。 まとめ 今回は GitHub Codespacesについて紹介しました。 GitHub Codespacesは、 クラウド 上に開発環境を提供することで環境設定の手間を省き、どこからでも安全に開発を行うことができる便利なツールです。ただし、料金や対応できない開発がある事など一部の問題を理解した上で使用することが重要となります。 私たちは同じチームで働いてくれる仲間を探しています。今回のエントリで紹介したような仕事に興味のある方、ご応募お待ちしています。 ソリューションアーキテクト 執筆: @yamashita.tsuyoshi 、レビュー: 寺山 輝 (@terayama.akira) ( Shodo で執筆されました )
はじめに こんにちは。X(クロス) イノベーション 本部 ソフトウェアデザインセンター の山下です。 みなさん GitHub Codespaces( https://github.co.jp/features/codespaces ) をご存知ですか? 昨年一般提供された GitHub で利用できる 統合開発環境 ( IDE )です。 GitHub Codespacesは、ブラウザ上で動作する開発環境となります。 GitHub から起動することができて、ブラウザが動く環境であれば、追加でソフトウェアのインストールを必要とせず、いつでもどこでも開発作業が可能です。 この記事では、その特徴、使い方、そして便利な点について解説します。特にチームの開発で開発環境を統一したい場合や素早く開発環境を用意したい場合には GitHub Codespaces を検討しても良いかもしれません。 GitHub Codespacesの特徴 GitHub Codespacesは、 クラウド 上に開発環境を構築し、 クラウド 上で完結する開発環境です。開発者はデ バイス や場所に依存せず、どこからでもコードの編集や実行が可能になります。従来の開発環境では、各個人のPCに環境を構築する必要がありましたが、Codespacesを使用するとその必要がありません。 Visual Studio Code と完全に統合されているため、その強力な機能を利用できます。コードの補完、 シンタックス ハイライト、 リファクタリング 、 デバッグ といった機能はもちろんのこと、 拡張機能 をインストールしてカスタマイズすることも可能です。 この記事では GitHub Codespacesの概要を紹介しますが、詳細は公式のドキュメントも参照して下さい。 https://docs.github.com/ja/codespaces GitHub Codespacesの利用方法 GitHub Codespacesの利用はとても簡単です。 GitHub の リポジトリ ページにアクセスし、"Code"ボタンの下にある"Open with Codespaces"を選択するだけでCodespaceを作成できます。 Codespaceが作成されると、その リポジトリ 専用に GitHub が提供している クラウド 上に開発環境が構築され、アクセスすると Visual Studio Code がブラウザで立ち上がります。 しかも リポジトリ の ソースコード は既に git clone されていて、すぐにコードの編集や デバッグ を開始できます。また起動した Visual Studio Code はローカルで使用しているのとほぼ同じ機能が実現されています。普段から Visual Studio Code に慣れている開発者であれば、普段通りコードの編集や デバッグ が可能です。もちろん、コード補完、 シンタックス ハイライト、 リファクタリング 、 拡張機能 も利用できます。 GitHub Codespacesの便利な点 ここでは、自分が GitHub Codespacesを使っていて便利だと思った点をいくつか紹介していこうと思います。 Dev Containerを使った環境のカスタマイズ GitHub Codespacesでは、Dev Containerを使った開発環境のカスタマイズを行うことも可能です。 .devcontainer/devcontainer.json を作成して、Dev Container環境を作成することが出来ます。この設定ファイルでは、使用する基本イメージの指定、必要なツールのインストール、VisualStudio Codeの設定、ポートの開放などを定義できます。 先日、このテックブログにもDev Containerを使った Python の開発環境を構築する記事が公開されています。Dev Containerの設定についてはそちらが参考になるかと思います。 Dev Containerを使ってステップバイステップで作るPythonアプリケーション開発環境 Dev Container用の設定ファイルは、 リポジトリ に含めて共有することで、チーム全体で統一した開発環境を簡単に構築できます。 Dev Containerの設定については、 GitHub Codespacesにも説明があるので確認してみてください。 https://docs.github.com/ja/codespaces/setting-up-your-project-for-codespaces/adding-a-dev-container-configuration/introduction-to-dev-containers GitHub Codespacesでの 拡張機能 の扱い GitHub Codespaces上では起動した環境上でMarketplace から 拡張機能 のインストールが可能です。 もし普段、 Visual Studio Code の設定の同期機能を利用している場合であれば、 GitHub Codespacesとも設定を同期させることも可能です。VisualStudio Codeの設定の保存に GitHub を利用している場合、起動した GitHub CodeSpacesに同期している設定が自動的に読み込まれます。この機能とDev Containerを組み合わせることで柔軟に開発環境を構築することが可能となります。Dev Containerではチームで使う 拡張機能 を共通でインストールして、 Visual Studio Code の同期機能で個人の 拡張機能 をインストールするというような運用が可能となります。 GitHub Codespacesのパーソナライズの項目に説明があるので確認してみてください。 https://docs.github.com/ja/enterprise-cloud@latest/codespaces/customizing-your-codespace/personalizing-github-codespaces-for-your-account GitHub との統合 GitHub との統合も大きな特徴の一つです。起動した GitHub Codespacesの環境では起動した状態で git clone が実行されている状態であることは紹介しました。さらに、 GitHub への認証が完了している状態で起動しているので、 リポジトリ へのコードの git push といった操作を行なう際に追加の設定作業は不要です。起動して、コードを修正して、テストして、すぐにpushするという作業に集中できます。 GItHub 用の 拡張機能 もインストールされているため、Pull Requestの作成、マージ、コードレビュー、イシューの管理も行えます。 クラウド 上に開発環境があることのメリット ここまでで、 GitHub Codespacesの便利な機能を簡単に紹介しました。この他に GitHub Codespacesが クラウド 環境に開発環境が構築されることによるメリットもあります。 開発環境の構築が高速 まず一つ目として、新規プロジェクトを開始する際に、従来のローカル環境と比べて環境設定の時間を大幅に短縮できる点が挙げられます。新たに必要なツールやライブラリをインストールする手間が省け、すぐに開発に取りかかることができます。 開発で利用するマシンが自由 二つ目として、異なるOSやデ バイス での開発が容易になる点です。たとえば、普段は Mac を使って開発している人でもブラウザさえ起動できれば同一の環境がすぐ手に入ります。 開発環境の統一の実現 そして、最も大きな利点として、チームの効率化を挙げることができます。全てのチームメンバーが同じ開発環境を簡単に共有できるため、環境差による問題が大幅に減少します。また、Codespaceはブラウザ上で動作するため、どのデ バイス からでもアクセスできます。これにより、場所にとらわれずに作業が可能となります。 GitHub Codespacesの注意点と制限 使用料金はリソースの使用量によります。無駄な費用を避けるためには、使用しないCodespacesは閉じるなど、適切な管理が求められます。Codespacesは開発環境を起動している時間と利用しているストレージの料金の2種類が請求されます。使っていない環境は自動的に削除されるので、無駄な起動時間の請求はあまり発生しないと思います。しかし、ストレージの方は自動的に削除されないため注意が必要となります。 GitHub Codespacesの利用料金は以下のページを参照してください。 https://docs.github.com/ja/billing/managing-billing-for-github-codespaces/about-billing-for-github-codespaces また、一部のハードウェア依存の開発や、特定の開発を GitHub Codespacesでは行なうことが出来ません。例えば、高度な3Dグラフィックスを必要とする開発作業や、特別なハードウェアに依存した開発作業は、現在のところCodespacesではサポートされていません。 まとめ 今回は GitHub Codespacesについて紹介しました。 GitHub Codespacesは、 クラウド 上に開発環境を提供することで環境設定の手間を省き、どこからでも安全に開発を行うことができる便利なツールです。ただし、料金や対応できない開発がある事など一部の問題を理解した上で使用することが重要となります。 私たちは同じチームで働いてくれる仲間を探しています。今回のエントリで紹介したような仕事に興味のある方、ご応募お待ちしています。 ソリューションアーキテクト 執筆: @yamashita.tsuyoshi 、レビュー: 寺山 輝 (@terayama.akira) ( Shodo で執筆されました )
こんにちは、金融ソリューション事業部の岡崎、若本です。 3DCG空間では、オブジェクトに関する情報を過度に表示し過ぎることはユーザー体験を阻害する要因になります。その一方で、あまりに情報が少ないと世界観の欠如に繋がります。そのため、質問応答などのユーザーが求める情報を必要なだけ表示する仕組みが求められることがあります。 そこで今回は、 Unreal Engine (UE)上のオブジェクトの情報についてChatGPTに問い合わせる実装を行ってみたいと思います。 下図のように、あらかじめ 格納した関連文献を基に、UE上でObjectに対する質問をChatGPTに答えさせる ことがゴールです。 準備するもの 処理概要 手順①. GPTを用いたAPIを作成 1.1 APIの環境構築 1.2 エンドポイントの作成 1.3 機能の作成 1.4 動作確認(データ登録) 1.5 動作確認(検索) 手順②. オブジェクトの準備 2.1 チャット可能な範囲に入ったとき、アイコンを表示する機能 2.2 オブジェクト(Actor)をクリックしてチャット画面を表示する機能 手順③. チャットUIの実装 3.1 チャットのページUIを実装 3.2 チャットの吹き出しUIを実装 3.2.1 自分のチャットを表示する 3.2.2 GPTの返信を表示する 手順④. HTTPリクエスト用のC++クラス作成 4.1 C++クラスを作成 4.2 C++クラスを編集 4.3 作成したクラスをブループリント化 手順⑤. 質問送信時の処理を実装 5.1 「送信」ボタンが押されたとき、送信した内容を表示する 5.2 HTTPリクエストを送信する 5.3 GPTの返信内容を表示する デモ動画 おわりに 準備するもの Unreal Engine 5.2.0 OpenAI API GPT-3.5-turbo(0301)を使用します。 事前にユーザー登録と、 API キーの発行を実施しておきます。 FastAPI API として、下記の機能を作成します。 オブジェクトに関連したドキュメントを登録する オブジェクト名と質問を与え、答えを返す Docker API の環境構築に使用します。 処理概要 下記の処理を実装します。 それぞれの機能について、()内の担当者が作業を行いました。 コリジョン 判定(岡崎) チャット可能な範囲に入ったとき、アイコンを表示します。 チャットUIの表示(岡崎、若本) コリジョン 状態でオブジェクト(Actor)をクリックし、チャット画面を表示します。 GPTへのリク エス ト(若本) UE上の情報を基に、GPTにリク エス トを送ります。 結果の表示(若本) API から返ってきたレスポンスをUIに反映します。 以降では、各機能の概要や実装について説明します。 手順①. GPTを用いた API を作成 本手順は若本が説明します。 GPTはUE内のオブジェクトについて情報を持たないため、そのままChatGPTに問い合わせても的確に質問に答えることができません。 そこで、以下の2つの機能を持つ API を作成します。今回は Unreal Engine を実行するPC上で構築することとしました。 オブジェクトに関連したドキュメントを登録する(インデックス登録) オブジェクト名と質問を与え、答えを返す(問い合わせ) それぞれの機能についてエンドポイントを用意し、HTTPリク エス トで実行できるようにします。 最終的には下記のようなファイル構成となります。 1.1 API の環境構築 まずはDockerで API のベースとなる環境を作成します。 以下は最終的に使用したDockerの設定ファイルです。 フォルダ上でdocker containerをupするとアプリケーションが起動します( http://localhost:8000/docs/ からアクセスすることができます)。 docker-compose.yml(クリックで表示) version: '3' services: backend: container_name: gpt-api build: context: . dockerfile: ./Dockerfile volumes: \- type: bind source: './api' target: '/src' ports: \- 8000:8000 stdin_open: true Dockerfile(クリックで表示) FROM python:3.8-slim-buster RUN pip install --upgrade pip RUN pip install --upgrade setuptools COPY requirements.txt / RUN pip install -r requirements.txt WORKDIR /src CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--reload"] requirements.txt(クリックで表示) numpy openai transformers llama_index langchain fastapi uvicorn[standard] pandas 1.2 エンドポイントの作成 次に、 API が持つ2つの機能についてエンドポイントを作成していきます。 routers/index.pyにインデックス登録のエンドポイントを、routers/search.pyに問い合わせ用のエンドポイントを記述しています。 main.py(クリックで表示) from fastapi import FastAPI from routers import search, index import os os.environ[ 'OPENAI_API_KEY' ] = 'YOUR_OPENAI_API_TOKEN' app = FastAPI() app.include_router(search.router) app.include_router(index.router) routers/index.py(クリックで表示) from fastapi import APIRouter from functions.index_operation import create_index router = APIRouter() @ router.post ( "/index" ) def service_create_index (dir_name: str ): _ = create_index(dir_name) return 200 routers/search.py(クリックで表示) from fastapi import APIRouter from pydantic import BaseModel from functions.search_operation import search router = APIRouter() class SearchRequestItem (BaseModel): dir_name: str = None query: str = None class SearchResponseItem (BaseModel): text: str = None @ router.post ( "/search" ) def service_search (item: SearchRequestItem) -> SearchResponseItem: response_text = search(item.dir_name, item.query) response = SearchResponseItem(text=response_text) return response 1.3 機能の作成 最後に、それぞれのエンドポイントで呼び出される処理を記述します。 functions/index_operation.pyにインデックス登録の処理を、functions/search_operation.pyに問い合わせの処理を実装しました。functions/ api _handling.pyでは、使用するmodel(gpt-3.5-turbo)の設定を行っています。 ここで、インデックス登録はドキュメントをあらかじめベクトルにしておき、問い合わせが来た際に検索するために行います。 今回の実装では、フォルダ内の CSV /PDF/TXTファイルについて読み込みベクトル化できるようにしました。 ※データの大きさや権限によってエラーが発生する可能性はありますが、今回そのエラーハンドリングまでは行っていません。 functions/api_handling.py(クリックで表示) from llama_index import LLMPredictor, ServiceContext, PromptHelper from langchain.chat_models import ChatOpenAI def get_service_context (): llm_predictor = LLMPredictor( llm=ChatOpenAI( temperature= 0 , model_name= "gpt-3.5-turbo" , max_tokens= 512 ) ) prompt_helper = PromptHelper( max_input_size= 4096 , num_output= 256 , max_chunk_overlap= 20 ) service_context = ServiceContext.from_defaults(llm_predictor=llm_predictor, prompt_helper=prompt_helper) return service_context functions/index_operation.py(クリックで表示) from llama_index import GPTVectorStoreIndex, SimpleDirectoryReader, download_loader from functions.api_handling import get_service_context import glob def create_index (dir_name: str ): input_dir = f 'data/{dir_name}' service_context = get_service_context() index = None all_files = glob.glob(f '{input_dir}/*' ) for file in all_files: if file .endswith( '.csv' ): Reader = download_loader( "SimpleCSVReader" ) loader = Reader() docs = loader.load_data( file = file ) if file .endswith( '.pdf' ): Reader = download_loader( "CJKPDFReader" ) loader = Reader() docs = loader.load_data( file = file ) if file .endswith( '.txt' ): loader = SimpleDirectoryReader(input_files=[ file ], required_exts=[ 'txt' ]) docs = loader.load_data() if index is None : index = GPTVectorStoreIndex.from_documents( docs, service_context=service_context ) else : for doc in docs: index.insert(doc) # indexを保存する if index: index.storage_context.persist(f "index/{dir_name}" ) return index functions/search_operation.py(クリックで表示) from llama_index import StorageContext, load_index_from_storage from functions.api_handling import get_service_context def load_index (dir_name: str ): \ # インデックスの読み込み storage_context = StorageContext.from_defaults(persist_dir=f "index/{dir_name}" ) index = load_index_from_storage(storage_context, service_context=get_service_context()) return index def search (dir_name: str , query: str ): index = load_index(dir_name) \ # クエリエンジンの作成 query_engine = index.as_query_engine() text_query = f '下記の質問に対して、敬語で簡潔に答えてください。 \n {query}' response = query_engine.query(text_query) return response.response 1.4 動作確認(データ登録) 上記のコードを記述後、動作確認を実施します。 アプリを立ち上げ後、以下のURLにアクセスすることでSwagger UI(下図)が表示され、直接動作確認をすることができます。 http://localhost:8000/docs/ まずはデータを登録してみましょう。 dataフォルダ下に新しいフォルダを作成します。ここでは、「BookInfo」というフォルダを作成しました。 なお、検索時にもこのフォルダ名を使用します。 ※ フォルダ名をもとにした管理やリク エス トは少し危険ですが、今回は簡単な検証のため実装を簡略化しています。 フォルダ作成後、「BookInfo」フォルダ内に CSV /PDF/TXTファイルを格納します。 今回は自作のsample.txtを格納しました。ファイルの中身は下記のようになっています。 格納後、 http://localhost:8000/docs/ にアクセスし、/indexから下記のようにインデックス登録を実行します。 indexフォルダ下に同名のフォルダが作成されます。 下記の3種類の json ファイルが入っていれば正常にインデックスが登録できています。 1.5 動作確認(検索) 検索についても動作確認してみます。 http://localhost:8000/docs/ にアクセスし、/searchを下記のように実行してみます。 少しするとResponseが返ってきます。登録した文章の情報を基に答えられており、動作が確認できました。 複数インデックスを登録した場合はdir_nameのパラメータを変えることで、検索対象を変えて回答させることができます。 手順②. オブジェクトの準備 次に、UE上で使用するオブジェクトの準備を行っていきます。本手順は岡崎が説明します。 ここでは以下の機能や画面を作成します。 チャット可能な範囲に入ったとき、アイコンを表示する機能 オブジェクト(Actor)をクリックしてチャット画面を表示する機能 それでは各機能について説明していきます。 2.1 チャット可能な範囲に入ったとき、アイコンを表示する機能 今回はゲームでよくあるような、プレイヤーがオブジェクトに一定の距離近づくと、「ここにアイテムがあります」という意味のアイコンが表示される処理の作成。 また、さらに近づくと「アイテムにクリックできます」という意味のアイコンに変更し、アイテムがクリックできるようになる処理を作っていきます。 まずはプレイヤーがオブジェクトに近づいたことを判定する処理を作成します。 以前 こちらの記事(UE5でコリジョン(衝突)判定機能を使って色々な機能を作成してみた) でも紹介しましたが、 コリジョン 機能を使っていきます。 まずはアクターブループリントを作成し「BP_P_Item」と 命名 します。 コンポーネント 追加から、「 Sphere Collision」を2つ追加し、それぞれ「BigSphere」と「SmallSphere」とします。 この2つの コリジョン は、「BigSphere」にプレイヤーが入った時、アイテムの上にアイコンを表示し、「SmallSphere」に入った時、アイコンを変更してアイテムをクリックできる状態に変更するために使用します。 上記を再現するため、「BigSphere」を大きく作成し、その中に「SmallSphere」を配置しています。 次にアイテムの上に表示するアイコンを作成します。 ユーザーインターフェース から ウィジェット ブループリントを作成し、「WBP_ItemIcon」と 命名 します。 作成後、下記画像のようにアイコンの ウィジェット を作成し、「Is Variable」にチェックを入れておきます。 次に コリジョン 用のブループリント(BP_P_Item)に戻り、「SmallSphere」の下に「Static Mesh」と「 Widget 」の コンポーネント を追加します。 画像では Widget の名前をWidgetItemIconに変更しています。 「 Widget 」の コンポーネント の詳細タブより、「 Widget Class」を先ほど作成した ウィジェット ブループリントの名前(WBP_ItemIcon)に変更します。 また、同じ詳細タブより「 レンダリング 」内の「Visible」のチェックを外しておきます。 次にイベントグラフに移動し、「On Component Begin Overlap(BigSphere)」と「On Component End Overlap(BigSphere)」のノードを作成します。 このノードは、左側の コンポーネント 一覧から「BigSphere」を右クリックし、イベント追加から選ぶことができます。 下記画像のように、プレイヤーが「BigSphere」内に入った際に、デフォルトで見えない設定にしておいたアイコン ウィジェット の「Visibility」の値を変更し、画面上に レンダリング させる処理を作成します。 同様にプレイヤーが「BigSphere」からプレイヤーが出た際にアイコンを見えなくする処理も追加します。 ここまでで、下記のようにプレイヤーの位置によってアイコンが表示されたり、消したりする処理ができました。 続いてさらに近づいた際(SmallSphereに入った際)にアイコンの表示を変更する処理を作成します。 アイコン用のブループリント(WBP_ItemIcon)に戻り、アイコンを変更するための関数を「AreaEvent」という名前で作成します。 「AreaEvent」には引数として「IsInside」というBoolean値を設定しておきます。 下記画像のように「AreaEvent」から「IsInside」の値によってイメージ画像を変更するために「Set Brush from Texture」ノードを繋ぎます。 「選択する」ノードに、Falseの場合にはSmallSphereに入っていない時用の画像を設定し、Trueの場合はSmallSphereに入っている時用の画像を設定します。 (本プロジェクトの場合は下記画像の「arrow」と「circle」を使用) 「Set Brush from Texture」のターゲットには ウィジェット ブループリントでimageを作成した際に 命名 した名前の変数が左側のタブに追加されているのでそれを使用します。 次に コリジョン 用のブループリント(BP_P_Item)に戻り、「SmallSphere」に入った際にアイコン ウィジェット の画像を変えるために「AreaEvent」の関数を使用する処理を作成します。 「SmallSphere」から「On Component Begin Overlap(SmallSphere)」と「On Component End Overlap(SmallSphere)」のノードを作成します。 下記画像のように「On Component Begin Overlap(SmallSphere)」と「On Component End Overlap(SmallSphere)」を「Area Event」繋げます。 「Area Event」の引数としてプレイヤーが「SmallSphere」の内部にいるかどうかの値を変数にして「In Small Collision Area」とします。 (後述の処理で使用するため変数にしておきます。) ここまでの処理で「BigSphere」の内部にいる時に矢印のアイコンを出現させ、「SmallSphere」の内部にいる時に二重丸のアイコンに変更させる処理ができました。 2.2 オブジェクト(Actor)をクリックしてチャット画面を表示する機能 続いて実際にオブジェクトを配置し、「SmallSphere」の内部にプレイヤーがいる時のみオブジェクトにクリックができる機能と、クリックした後にチャット画面を表示する機能を作成します。 まず初めに、プレイ画面上にマウスカーソルを出現させ、クリックを有効にするために、レベルブループリントを開きます。下の画像のように「イベントBeginPlay」に「Show Mouse Cursor」と「Enable Click Events」の設定をどちらもオンに変更します。 これでプレイ画面上にマウスカーソルを出現させ、クリックができるように設定されました。 次に、先ほど作成した コリジョン 用のブループリント(BP_P_Item)から子ブループリントを作成します。 親のブループリント上で右クリックを行い、「子ブループリントクラスを作成します」ボタンを押し「BP_C_Item_Book」と 命名 します。 子ブループリントを開き、親ブループリント作成時は特に編集しなかった「Static Mesh」に任意のメッシュ素材を選びます。 今回は「Quixel Bridge」内のメッシュを使用しました。 こちらの記事(Unreal Engine 5 を使ってワールドの地形を作成してみました) で、「Quixel Bridge」の使用方法を紹介しています。 イベントグラフを開き、「イベント ActorOnClicked」のノードを追加します。 「In Small Collision Area」の値からIF文を作成し、プレイヤーが「Small Collision」の中にいる場合のみチャット画面用の ウィジェット を作成するために「 ウィジェット を作成」ノードを繋げ、「Add Viewport」を付けます。 ここでチャット画面用の ウィジェット ブループリントを作成します。 (チャット機能の詳細は後述するので、ここでは大枠だけ作成します。) クリックしたいオブジェクト用(今回は本のオブジェクト)の ウィジェット ブループリントを作成し、「WBP_BookInfo」と 命名 します。 「 Canvas Panel」内に、本のタイトルや、サムネイル用画像を置き、チャット用のスペースも確保します。 こちらの記事(UE5でコリジョン(衝突)判定機能を使って色々な機能を作成してみた) で、 ウィジェット の作成方法について触れているので参考にしてください。 今回は閉じるボタン(×マーク)を押すことで ウィジェット を閉じる挙動にしたいので、「 Canvas Panel」内に、ボタンも追加します。 パレット追加欄に「Button」と検索し、ボタン内部に「Text」で「×」を記述し閉じるボタンを作成しました。 (ボタンの名前を「CloseButton」に変更してあります。) 階層タブより「CloseButton」を選択中に、詳細タブ内のイベント>On Click の追加ボタンを押し、イベントグラフに「On Click(CloseButton)」ノードを追加します。 「On Click(CloseButton)」ノードに「Remove from Parent」を繋ぎ、閉じるボタンが押された時チャット画面用の ウィジェット が閉じるようにします。 「BP_C_Item_Book」を再び開き、「 ウィジェット を作成」ノードの「Class」を今作成した「WBP_BookInfo」に変更します。 これで画面上の本のオブジェクトをクリックした際に、画面上にチャット画面の ウィジェット が現れ、閉じるボタンで閉じる処理ができました。 ただこのままだと、本のオブジェクトをクリックした分だけ ウィジェット が開いてしまうので、現在 ウィジェット が開かれている状態かどうかを判別するために「IsOpenDetailWidget」という変数を作成し、下記画像のようにフラグとして使用します。 「WBP_BookInfo」でチャット画面を閉じた後で「IsOpenDetailWidget」の変数をFalseに戻しておきます。 ここまでで、オブジェクト(Actor)をクリックしてチャット画面を表示する機能が完成しました。 手順③. チャットUIの実装 ここまでで、ベースとなるUEのオブジェクトやUI、外部 API の作成が完了しました。 以降の手順は若本が説明します。 本手順では、GPTのリク エス トとのやり取りに必要になる追加のUIを用意していきます。 3.1 チャットのページUIを実装 次に、チャットのやり取りを行うためのUIを作成します。 チャットの 吹き出し は動的に追加されていくため、ここでは必要な コンポーネント のみ用意します。 まずはチャット画面のベースとして、手順②で作成した「WBP_BookInfo」にText_box、button、scroll boxを追加します。 Text_boxとscroll boxをわかりやすい変数名に変更しておきます。 また、呼び出すために「Is Variable」にチェックを入れておきます。 次に、buttonにOnClickイベントを追加します。 詳細画面のOnClickを押すことで、OnClickイベントが有効になります。こちらを押しておきましょう。 詳細な処理は後の手順で作成します。 また、ここでItemの属性値を設定しておきます。 属性値を用いて後段でGPTに問い合わせる際の検索対象を変更するため、その下準備となります。 まずは Widget の「WBP_BookInfo」に変数「WidgetAttributes」を作成します。 次に、「BP_C_ItemBook」クラスに属性値を設定しておきます。ここでは変数名を「ItemAttributes」、デフォルト値を「BookInfo」としました。 最後に、「BP_C_ItemBook」のブループリント上で、 ウィジェット の作成時に自身の持つ「ItemAttributes」を「WidgetAttributes」にセットする処理を追加します。これで完成です。 3.2 チャットの 吹き出し UIを実装 3.1でやりとりを行う画面は作成しましたが、メッセージを入れるための 吹き出し がありません。 そこで、別途チャットの 吹き出し 用UI(自分用/GPT用)を作成します。 それぞれ ユーザーインターフェース から ウィジェット ブループリントを作成しましょう。 作成後、 Canvas PanelとText Box(Multi-Line)を配置します。 こちらもText Boxはわかりやすい変数名に変更し、「Is Variable」にチェックを入れておきます。 3.2.1 自分のチャットを表示する 自分用の 吹き出し は下記のように設定しました。背景色とFontの色/サイズを調整しています。 3.2.2 GPTの返信を表示する GPT用の 吹き出し も同様に作成します。 手順④. HTTPリク エス ト用の C++ クラス作成 4.1 C++ クラスを作成 Unreal Engine では、デフォルトでHTTPへのリク エス トを送信するクラスは用意されていないため、 API にリク エス トを送り、レスポンスを受け取るクラスを新規作成する必要があります。 ブループリントでは実現できないため、今回は C++ で実装することとしました。 まずは、ツールから新規の C++ クラスを作成します。 ここで、親クラスは「Actor」、名前は「HTTPActor」としました。 クラスを作成すると、エディタに飛ばされます。エディタ上でクラスを編集していきます。 4.2 C++ クラスを編集 API にリク エス トを送り、レスポンスを受け取るための処理を追加していきます。 まずは、Build.csのDependencyModuleに"HTTP", " Json ", "JsonUtilities"の3つを追加します。 Build.cs(クリックで表示) // Fill out your copyright notice in the Description page of Project Settings. using UnrealBuildTool; public class TechBlog9 : ModuleRules { public TechBlog9(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HTTP", "Json", "JsonUtilities" }); PrivateDependencyModuleNames.AddRange(new string[] { }); // Uncomment if you are using Slate UI // PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" }); // Uncomment if you are using online features // PrivateDependencyModuleNames.Add("OnlineSubsystem"); // To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true } } また、Engine.iniの末尾にHTTPリク エス トの設定を記載しておきます。 Engine.ini(クリックで表示) [HTTP] HttpTimeout=60 HttpConnectionTimeout=5 HttpReceiveTimeout=-1 HttpSendTimeout=-1 HttpMaxConnectionsPerServer=32 bEnableHttp=true bUseNullHttp=false HttpDelayTime=0.1 上記が終わったら、HTTPActor.hクラスを変更します。 筆者はEpicのcommunityを参考に#include "CoreMinimal.h"を コメントアウト しました。 後ほどブループリント上で呼び出すため、HTTPリク エス トを実行するHttpMethodはUFUNCTIONを、結果を格納する変数である outputStringにはUPROPERTYを設定しておきます。 HTTPActor.h(クリックで表示) // Fill out your copyright notice in the Description page of Project Settings. #pragma once // #include "CoreMinimal.h" #include "GameFramework/Actor.h" #include "Http.h" #include "HTTPActor.generated.h" UCLASS() class TECHBLOG9_API AHTTPActor : public AActor { GENERATED_BODY() public: // Sets default values for this actor's properties AHTTPActor(); protected: // Called when the game starts or when spawned virtual void BeginPlay() override; public: // Called every frame virtual void Tick(float DeltaTime) override; FHttpModule* Http; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Default) FString outputString; // HTTP通信 UFUNCTION(BlueprintCallable, Category = "Http") void HttpMethod(FString query, FString dir_name); // レスポンス後のイベント処理 void OnResponseReceived(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful); }; 最後に、HTTPActor.cppに具体的な処理内容を記述します。 手順①で実装した API の仕様に合わせ、dir_name(インデックスのフォルダ名)とquery(質問)の2つを引数としてHTTPリク エス トを送り、返ってきた Json の「text」をoutputStringに格納する処理を追加しました。 HTTPActor.cpp(クリックで表示) // Fill out your copyright notice in the Description page of Project Settings. #include "HTTPActor.h" // Sets default values AHTTPActor::AHTTPActor() { // Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it. PrimaryActorTick.bCanEverTick = true; Http = &FHttpModule::Get(); } // Called when the game starts or when spawned void AHTTPActor::BeginPlay() { Super::BeginPlay(); } // Called every frame void AHTTPActor::Tick(float DeltaTime) { Super::Tick(DeltaTime); } void AHTTPActor::HttpMethod(FString query, FString dir_name) { // Jsonデータ作成 TSharedPtr<FJsonObject> JsonObject = MakeShareable(new FJsonObject); JsonObject->SetStringField("dir_name", dir_name); JsonObject->SetStringField("query", query); // OutputStringに Json 格納 FString OutputString; TSharedRef<TJsonWriter > JsonWriter = TJsonWriterFactory<>::Create(&OutputString); FJsonSerializer::Serialize(JsonObject.ToSharedRef(), JsonWriter); // HTTPリク エス ト TSharedRef Request = Http->CreateRequest(); Request->OnProcessRequestComplete().BindUObject(this, &AHTTPActor::OnResponseReceived); Request->SetURL(" http://localhost:8000/search "); Request->SetVerb("POST"); Request->SetHeader(TEXT("User-Agent"), "X-UnrealEngine-Agent"); Request->SetHeader("Content-Type", TEXT("application/ json ")); Request->SetContentAsString(OutputString); Request->ProcessRequest(); } void AHTTPActor::OnResponseReceived(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful) { TSharedPtr<FJsonObject> JsonObject; TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString()); if (FJsonSerializer::Deserialize(Reader, JsonObject)) { // Json からtextを抽出 outputString = JsonObject->GetStringField("text"); } } 上記の変更が終わったらbuildします。 筆者は VS Code で編集していたため、Launch<project_name>Editor(development)でbuildしました。 Unreal Engine が起動され、projectが表示されれば C++ クラスの作成は完了です。 4.3 作成したクラスをブループリント化 最後に、作成した C++ クラスをブループリントから呼び出すため、Blueprintクラスを作成します。 名前は「BP_HTTPActor」としました。 作成した「BP_HTTPActor」をワールドに配置すれば完了です。 手順⑤. 質問送信時の処理を実装 ここまででオブジェクトの準備、GPTの準備、UIの準備が全て終わりました。 最後に、Brueprint上ですべてを繋ぎこむ処理を「BP_C_Item_Book」のブループリント上で実装します。 以下が本手順で作成したブループリントの全体像です。 長くて見づらいため、それぞれ分けて解説します。(見やすさのため、拡大時にレイアウトを調整しています) 3.1で作成したOnclickイベント(チャットの送信ボタン押下)を起点としてブループリントを作成しました。 5.1 「送信」ボタンが押されたとき、送信した内容を表示する 送信ボタンが押下されると、TextBoxの値を取得して新しい 吹き出し として表示します。 ここでは、3.2.1で作成した自分用の 吹き出し を作成し、TextBoxの値を入れてScroll Boxの中に入れています。 5.2 HTTPリク エス トを送信する 4.3で配置した「BP_HTTPActor」クラスを呼び出し、HTTPMethodを実行します。ユーザーの入力と ウィジェット の持つ変数「WidgetAttributes」の値をGPTの API 側へリク エス トとして送信しています。 このとき、outputStringの変数にGPTのレスポンスが格納されるまでは、Delayとloopを使用してwaitします。 また、HTTPリク エス トを送った後、自身が入力したテキストをクリアする処理を記述しました。 5.3 GPTの返信内容を表示する 5.1と同様、今度はGPT用の 吹き出し を作成し、outputStringの値を入れてScroll Boxの中に格納します。 最後に、outputStringの変数をクリアすれば処理は完了です。 デモ動画 本のオブジェクトに近づくことでタッチが可能になり、タッチすることでUIが表示されます。 さらにUI上で問い合わせると、オブジェクトの関連文章を基に回答できていることを確認できます。 これまでの実装により、ストレスの少ない問い合わせをUE上で実現することができました。 おわりに 今回はチャットUIを用いた、オブジェクトに対する問い合わせを実装しました。 以下、実装を担当した岡崎と若本のそれぞれの所感となります。 岡崎:今回の実装で、ブループリントでのチェンジイベント(オンクリックなど)や、 コリジョン イベントに関する使用方法の基礎的な学習を行えました。今後プロダクトを作成する際、プレイヤー起因で任意の情報を出したり、プレイヤー情報を変更させるといった機能を作成する際に、数多く使うことになる機能を作成することができたため、より応用的な使い方など引き続き調査したいと思います。 若本:UE初学者であり、今回広く Python の実装/ブループリントの実装/ C++ の実装を行いましたが、慣れて以降はストレスレスに開発を行うことができました。今回の実装を発展させ、非同期処理の実装や会話履歴の入れ込み、UIのリッチ化など、さらに作りこむことによってよりよいユーザー体験が得られることが期待できると感じました。 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ 執筆: @wakamoto.ryosuke 、レビュー: @yamada.y ( Shodo で執筆されました )
こんにちは、金融ソリューション事業部の岡崎、若本です。 3DCG空間では、オブジェクトに関する情報を過度に表示し過ぎることはユーザー体験を阻害する要因になります。その一方で、あまりに情報が少ないと世界観の欠如に繋がります。そのため、質問応答などのユーザーが求める情報を必要なだけ表示する仕組みが求められることがあります。 そこで今回は、 Unreal Engine (UE)上のオブジェクトの情報についてChatGPTに問い合わせる実装を行ってみたいと思います。 下図のように、あらかじめ 格納した関連文献を基に、UE上でObjectに対する質問をChatGPTに答えさせる ことがゴールです。 準備するもの 処理概要 手順①. GPTを用いたAPIを作成 1.1 APIの環境構築 1.2 エンドポイントの作成 1.3 機能の作成 1.4 動作確認(データ登録) 1.5 動作確認(検索) 手順②. オブジェクトの準備 2.1 チャット可能な範囲に入ったとき、アイコンを表示する機能 2.2 オブジェクト(Actor)をクリックしてチャット画面を表示する機能 手順③. チャットUIの実装 3.1 チャットのページUIを実装 3.2 チャットの吹き出しUIを実装 3.2.1 自分のチャットを表示する 3.2.2 GPTの返信を表示する 手順④. HTTPリクエスト用のC++クラス作成 4.1 C++クラスを作成 4.2 C++クラスを編集 4.3 作成したクラスをブループリント化 手順⑤. 質問送信時の処理を実装 5.1 「送信」ボタンが押されたとき、送信した内容を表示する 5.2 HTTPリクエストを送信する 5.3 GPTの返信内容を表示する デモ動画 おわりに 準備するもの Unreal Engine 5.2.0 OpenAI API GPT-3.5-turbo(0301)を使用します。 事前にユーザー登録と、 API キーの発行を実施しておきます。 FastAPI API として、下記の機能を作成します。 オブジェクトに関連したドキュメントを登録する オブジェクト名と質問を与え、答えを返す Docker API の環境構築に使用します。 処理概要 下記の処理を実装します。 それぞれの機能について、()内の担当者が作業を行いました。 コリジョン 判定(岡崎) チャット可能な範囲に入ったとき、アイコンを表示します。 チャットUIの表示(岡崎、若本) コリジョン 状態でオブジェクト(Actor)をクリックし、チャット画面を表示します。 GPTへのリク エス ト(若本) UE上の情報を基に、GPTにリク エス トを送ります。 結果の表示(若本) API から返ってきたレスポンスをUIに反映します。 以降では、各機能の概要や実装について説明します。 手順①. GPTを用いた API を作成 本手順は若本が説明します。 GPTはUE内のオブジェクトについて情報を持たないため、そのままChatGPTに問い合わせても的確に質問に答えることができません。 そこで、以下の2つの機能を持つ API を作成します。今回は Unreal Engine を実行するPC上で構築することとしました。 オブジェクトに関連したドキュメントを登録する(インデックス登録) オブジェクト名と質問を与え、答えを返す(問い合わせ) それぞれの機能についてエンドポイントを用意し、HTTPリク エス トで実行できるようにします。 最終的には下記のようなファイル構成となります。 1.1 API の環境構築 まずはDockerで API のベースとなる環境を作成します。 以下は最終的に使用したDockerの設定ファイルです。 フォルダ上でdocker containerをupするとアプリケーションが起動します( http://localhost:8000/docs/ からアクセスすることができます)。 docker-compose.yml(クリックで表示) version: '3' services: backend: container_name: gpt-api build: context: . dockerfile: ./Dockerfile volumes: \- type: bind source: './api' target: '/src' ports: \- 8000:8000 stdin_open: true Dockerfile(クリックで表示) FROM python:3.8-slim-buster RUN pip install --upgrade pip RUN pip install --upgrade setuptools COPY requirements.txt / RUN pip install -r requirements.txt WORKDIR /src CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--reload"] requirements.txt(クリックで表示) numpy openai transformers llama_index langchain fastapi uvicorn[standard] pandas 1.2 エンドポイントの作成 次に、 API が持つ2つの機能についてエンドポイントを作成していきます。 routers/index.pyにインデックス登録のエンドポイントを、routers/search.pyに問い合わせ用のエンドポイントを記述しています。 main.py(クリックで表示) from fastapi import FastAPI from routers import search, index import os os.environ[ 'OPENAI_API_KEY' ] = 'YOUR_OPENAI_API_TOKEN' app = FastAPI() app.include_router(search.router) app.include_router(index.router) routers/index.py(クリックで表示) from fastapi import APIRouter from functions.index_operation import create_index router = APIRouter() @ router.post ( "/index" ) def service_create_index (dir_name: str ): _ = create_index(dir_name) return 200 routers/search.py(クリックで表示) from fastapi import APIRouter from pydantic import BaseModel from functions.search_operation import search router = APIRouter() class SearchRequestItem (BaseModel): dir_name: str = None query: str = None class SearchResponseItem (BaseModel): text: str = None @ router.post ( "/search" ) def service_search (item: SearchRequestItem) -> SearchResponseItem: response_text = search(item.dir_name, item.query) response = SearchResponseItem(text=response_text) return response 1.3 機能の作成 最後に、それぞれのエンドポイントで呼び出される処理を記述します。 functions/index_operation.pyにインデックス登録の処理を、functions/search_operation.pyに問い合わせの処理を実装しました。functions/ api _handling.pyでは、使用するmodel(gpt-3.5-turbo)の設定を行っています。 ここで、インデックス登録はドキュメントをあらかじめベクトルにしておき、問い合わせが来た際に検索するために行います。 今回の実装では、フォルダ内の CSV /PDF/TXTファイルについて読み込みベクトル化できるようにしました。 ※データの大きさや権限によってエラーが発生する可能性はありますが、今回そのエラーハンドリングまでは行っていません。 functions/api_handling.py(クリックで表示) from llama_index import LLMPredictor, ServiceContext, PromptHelper from langchain.chat_models import ChatOpenAI def get_service_context (): llm_predictor = LLMPredictor( llm=ChatOpenAI( temperature= 0 , model_name= "gpt-3.5-turbo" , max_tokens= 512 ) ) prompt_helper = PromptHelper( max_input_size= 4096 , num_output= 256 , max_chunk_overlap= 20 ) service_context = ServiceContext.from_defaults(llm_predictor=llm_predictor, prompt_helper=prompt_helper) return service_context functions/index_operation.py(クリックで表示) from llama_index import GPTVectorStoreIndex, SimpleDirectoryReader, download_loader from functions.api_handling import get_service_context import glob def create_index (dir_name: str ): input_dir = f 'data/{dir_name}' service_context = get_service_context() index = None all_files = glob.glob(f '{input_dir}/*' ) for file in all_files: if file .endswith( '.csv' ): Reader = download_loader( "SimpleCSVReader" ) loader = Reader() docs = loader.load_data( file = file ) if file .endswith( '.pdf' ): Reader = download_loader( "CJKPDFReader" ) loader = Reader() docs = loader.load_data( file = file ) if file .endswith( '.txt' ): loader = SimpleDirectoryReader(input_files=[ file ], required_exts=[ 'txt' ]) docs = loader.load_data() if index is None : index = GPTVectorStoreIndex.from_documents( docs, service_context=service_context ) else : for doc in docs: index.insert(doc) # indexを保存する if index: index.storage_context.persist(f "index/{dir_name}" ) return index functions/search_operation.py(クリックで表示) from llama_index import StorageContext, load_index_from_storage from functions.api_handling import get_service_context def load_index (dir_name: str ): \ # インデックスの読み込み storage_context = StorageContext.from_defaults(persist_dir=f "index/{dir_name}" ) index = load_index_from_storage(storage_context, service_context=get_service_context()) return index def search (dir_name: str , query: str ): index = load_index(dir_name) \ # クエリエンジンの作成 query_engine = index.as_query_engine() text_query = f '下記の質問に対して、敬語で簡潔に答えてください。 \n {query}' response = query_engine.query(text_query) return response.response 1.4 動作確認(データ登録) 上記のコードを記述後、動作確認を実施します。 アプリを立ち上げ後、以下のURLにアクセスすることでSwagger UI(下図)が表示され、直接動作確認をすることができます。 http://localhost:8000/docs/ まずはデータを登録してみましょう。 dataフォルダ下に新しいフォルダを作成します。ここでは、「BookInfo」というフォルダを作成しました。 なお、検索時にもこのフォルダ名を使用します。 ※ フォルダ名をもとにした管理やリク エス トは少し危険ですが、今回は簡単な検証のため実装を簡略化しています。 フォルダ作成後、「BookInfo」フォルダ内に CSV /PDF/TXTファイルを格納します。 今回は自作のsample.txtを格納しました。ファイルの中身は下記のようになっています。 格納後、 http://localhost:8000/docs/ にアクセスし、/indexから下記のようにインデックス登録を実行します。 indexフォルダ下に同名のフォルダが作成されます。 下記の3種類の json ファイルが入っていれば正常にインデックスが登録できています。 1.5 動作確認(検索) 検索についても動作確認してみます。 http://localhost:8000/docs/ にアクセスし、/searchを下記のように実行してみます。 少しするとResponseが返ってきます。登録した文章の情報を基に答えられており、動作が確認できました。 複数インデックスを登録した場合はdir_nameのパラメータを変えることで、検索対象を変えて回答させることができます。 手順②. オブジェクトの準備 次に、UE上で使用するオブジェクトの準備を行っていきます。本手順は岡崎が説明します。 ここでは以下の機能や画面を作成します。 チャット可能な範囲に入ったとき、アイコンを表示する機能 オブジェクト(Actor)をクリックしてチャット画面を表示する機能 それでは各機能について説明していきます。 2.1 チャット可能な範囲に入ったとき、アイコンを表示する機能 今回はゲームでよくあるような、プレイヤーがオブジェクトに一定の距離近づくと、「ここにアイテムがあります」という意味のアイコンが表示される処理の作成。 また、さらに近づくと「アイテムにクリックできます」という意味のアイコンに変更し、アイテムがクリックできるようになる処理を作っていきます。 まずはプレイヤーがオブジェクトに近づいたことを判定する処理を作成します。 以前 こちらの記事(UE5でコリジョン(衝突)判定機能を使って色々な機能を作成してみた) でも紹介しましたが、 コリジョン 機能を使っていきます。 まずはアクターブループリントを作成し「BP_P_Item」と 命名 します。 コンポーネント 追加から、「 Sphere Collision」を2つ追加し、それぞれ「BigSphere」と「SmallSphere」とします。 この2つの コリジョン は、「BigSphere」にプレイヤーが入った時、アイテムの上にアイコンを表示し、「SmallSphere」に入った時、アイコンを変更してアイテムをクリックできる状態に変更するために使用します。 上記を再現するため、「BigSphere」を大きく作成し、その中に「SmallSphere」を配置しています。 次にアイテムの上に表示するアイコンを作成します。 ユーザーインターフェース から ウィジェット ブループリントを作成し、「WBP_ItemIcon」と 命名 します。 作成後、下記画像のようにアイコンの ウィジェット を作成し、「Is Variable」にチェックを入れておきます。 次に コリジョン 用のブループリント(BP_P_Item)に戻り、「SmallSphere」の下に「Static Mesh」と「 Widget 」の コンポーネント を追加します。 画像では Widget の名前をWidgetItemIconに変更しています。 「 Widget 」の コンポーネント の詳細タブより、「 Widget Class」を先ほど作成した ウィジェット ブループリントの名前(WBP_ItemIcon)に変更します。 また、同じ詳細タブより「 レンダリング 」内の「Visible」のチェックを外しておきます。 次にイベントグラフに移動し、「On Component Begin Overlap(BigSphere)」と「On Component End Overlap(BigSphere)」のノードを作成します。 このノードは、左側の コンポーネント 一覧から「BigSphere」を右クリックし、イベント追加から選ぶことができます。 下記画像のように、プレイヤーが「BigSphere」内に入った際に、デフォルトで見えない設定にしておいたアイコン ウィジェット の「Visibility」の値を変更し、画面上に レンダリング させる処理を作成します。 同様にプレイヤーが「BigSphere」からプレイヤーが出た際にアイコンを見えなくする処理も追加します。 ここまでで、下記のようにプレイヤーの位置によってアイコンが表示されたり、消したりする処理ができました。 続いてさらに近づいた際(SmallSphereに入った際)にアイコンの表示を変更する処理を作成します。 アイコン用のブループリント(WBP_ItemIcon)に戻り、アイコンを変更するための関数を「AreaEvent」という名前で作成します。 「AreaEvent」には引数として「IsInside」というBoolean値を設定しておきます。 下記画像のように「AreaEvent」から「IsInside」の値によってイメージ画像を変更するために「Set Brush from Texture」ノードを繋ぎます。 「選択する」ノードに、Falseの場合にはSmallSphereに入っていない時用の画像を設定し、Trueの場合はSmallSphereに入っている時用の画像を設定します。 (本プロジェクトの場合は下記画像の「arrow」と「circle」を使用) 「Set Brush from Texture」のターゲットには ウィジェット ブループリントでimageを作成した際に 命名 した名前の変数が左側のタブに追加されているのでそれを使用します。 次に コリジョン 用のブループリント(BP_P_Item)に戻り、「SmallSphere」に入った際にアイコン ウィジェット の画像を変えるために「AreaEvent」の関数を使用する処理を作成します。 「SmallSphere」から「On Component Begin Overlap(SmallSphere)」と「On Component End Overlap(SmallSphere)」のノードを作成します。 下記画像のように「On Component Begin Overlap(SmallSphere)」と「On Component End Overlap(SmallSphere)」を「Area Event」繋げます。 「Area Event」の引数としてプレイヤーが「SmallSphere」の内部にいるかどうかの値を変数にして「In Small Collision Area」とします。 (後述の処理で使用するため変数にしておきます。) ここまでの処理で「BigSphere」の内部にいる時に矢印のアイコンを出現させ、「SmallSphere」の内部にいる時に二重丸のアイコンに変更させる処理ができました。 2.2 オブジェクト(Actor)をクリックしてチャット画面を表示する機能 続いて実際にオブジェクトを配置し、「SmallSphere」の内部にプレイヤーがいる時のみオブジェクトにクリックができる機能と、クリックした後にチャット画面を表示する機能を作成します。 まず初めに、プレイ画面上にマウスカーソルを出現させ、クリックを有効にするために、レベルブループリントを開きます。下の画像のように「イベントBeginPlay」に「Show Mouse Cursor」と「Enable Click Events」の設定をどちらもオンに変更します。 これでプレイ画面上にマウスカーソルを出現させ、クリックができるように設定されました。 次に、先ほど作成した コリジョン 用のブループリント(BP_P_Item)から子ブループリントを作成します。 親のブループリント上で右クリックを行い、「子ブループリントクラスを作成します」ボタンを押し「BP_C_Item_Book」と 命名 します。 子ブループリントを開き、親ブループリント作成時は特に編集しなかった「Static Mesh」に任意のメッシュ素材を選びます。 今回は「Quixel Bridge」内のメッシュを使用しました。 こちらの記事(Unreal Engine 5 を使ってワールドの地形を作成してみました) で、「Quixel Bridge」の使用方法を紹介しています。 イベントグラフを開き、「イベント ActorOnClicked」のノードを追加します。 「In Small Collision Area」の値からIF文を作成し、プレイヤーが「Small Collision」の中にいる場合のみチャット画面用の ウィジェット を作成するために「 ウィジェット を作成」ノードを繋げ、「Add Viewport」を付けます。 ここでチャット画面用の ウィジェット ブループリントを作成します。 (チャット機能の詳細は後述するので、ここでは大枠だけ作成します。) クリックしたいオブジェクト用(今回は本のオブジェクト)の ウィジェット ブループリントを作成し、「WBP_BookInfo」と 命名 します。 「 Canvas Panel」内に、本のタイトルや、サムネイル用画像を置き、チャット用のスペースも確保します。 こちらの記事(UE5でコリジョン(衝突)判定機能を使って色々な機能を作成してみた) で、 ウィジェット の作成方法について触れているので参考にしてください。 今回は閉じるボタン(×マーク)を押すことで ウィジェット を閉じる挙動にしたいので、「 Canvas Panel」内に、ボタンも追加します。 パレット追加欄に「Button」と検索し、ボタン内部に「Text」で「×」を記述し閉じるボタンを作成しました。 (ボタンの名前を「CloseButton」に変更してあります。) 階層タブより「CloseButton」を選択中に、詳細タブ内のイベント>On Click の追加ボタンを押し、イベントグラフに「On Click(CloseButton)」ノードを追加します。 「On Click(CloseButton)」ノードに「Remove from Parent」を繋ぎ、閉じるボタンが押された時チャット画面用の ウィジェット が閉じるようにします。 「BP_C_Item_Book」を再び開き、「 ウィジェット を作成」ノードの「Class」を今作成した「WBP_BookInfo」に変更します。 これで画面上の本のオブジェクトをクリックした際に、画面上にチャット画面の ウィジェット が現れ、閉じるボタンで閉じる処理ができました。 ただこのままだと、本のオブジェクトをクリックした分だけ ウィジェット が開いてしまうので、現在 ウィジェット が開かれている状態かどうかを判別するために「IsOpenDetailWidget」という変数を作成し、下記画像のようにフラグとして使用します。 「WBP_BookInfo」でチャット画面を閉じた後で「IsOpenDetailWidget」の変数をFalseに戻しておきます。 ここまでで、オブジェクト(Actor)をクリックしてチャット画面を表示する機能が完成しました。 手順③. チャットUIの実装 ここまでで、ベースとなるUEのオブジェクトやUI、外部 API の作成が完了しました。 以降の手順は若本が説明します。 本手順では、GPTのリク エス トとのやり取りに必要になる追加のUIを用意していきます。 3.1 チャットのページUIを実装 次に、チャットのやり取りを行うためのUIを作成します。 チャットの 吹き出し は動的に追加されていくため、ここでは必要な コンポーネント のみ用意します。 まずはチャット画面のベースとして、手順②で作成した「WBP_BookInfo」にText_box、button、scroll boxを追加します。 Text_boxとscroll boxをわかりやすい変数名に変更しておきます。 また、呼び出すために「Is Variable」にチェックを入れておきます。 次に、buttonにOnClickイベントを追加します。 詳細画面のOnClickを押すことで、OnClickイベントが有効になります。こちらを押しておきましょう。 詳細な処理は後の手順で作成します。 また、ここでItemの属性値を設定しておきます。 属性値を用いて後段でGPTに問い合わせる際の検索対象を変更するため、その下準備となります。 まずは Widget の「WBP_BookInfo」に変数「WidgetAttributes」を作成します。 次に、「BP_C_ItemBook」クラスに属性値を設定しておきます。ここでは変数名を「ItemAttributes」、デフォルト値を「BookInfo」としました。 最後に、「BP_C_ItemBook」のブループリント上で、 ウィジェット の作成時に自身の持つ「ItemAttributes」を「WidgetAttributes」にセットする処理を追加します。これで完成です。 3.2 チャットの 吹き出し UIを実装 3.1でやりとりを行う画面は作成しましたが、メッセージを入れるための 吹き出し がありません。 そこで、別途チャットの 吹き出し 用UI(自分用/GPT用)を作成します。 それぞれ ユーザーインターフェース から ウィジェット ブループリントを作成しましょう。 作成後、 Canvas PanelとText Box(Multi-Line)を配置します。 こちらもText Boxはわかりやすい変数名に変更し、「Is Variable」にチェックを入れておきます。 3.2.1 自分のチャットを表示する 自分用の 吹き出し は下記のように設定しました。背景色とFontの色/サイズを調整しています。 3.2.2 GPTの返信を表示する GPT用の 吹き出し も同様に作成します。 手順④. HTTPリク エス ト用の C++ クラス作成 4.1 C++ クラスを作成 Unreal Engine では、デフォルトでHTTPへのリク エス トを送信するクラスは用意されていないため、 API にリク エス トを送り、レスポンスを受け取るクラスを新規作成する必要があります。 ブループリントでは実現できないため、今回は C++ で実装することとしました。 まずは、ツールから新規の C++ クラスを作成します。 ここで、親クラスは「Actor」、名前は「HTTPActor」としました。 クラスを作成すると、エディタに飛ばされます。エディタ上でクラスを編集していきます。 4.2 C++ クラスを編集 API にリク エス トを送り、レスポンスを受け取るための処理を追加していきます。 まずは、Build.csのDependencyModuleに"HTTP", " Json ", "JsonUtilities"の3つを追加します。 Build.cs(クリックで表示) // Fill out your copyright notice in the Description page of Project Settings. using UnrealBuildTool; public class TechBlog9 : ModuleRules { public TechBlog9(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HTTP", "Json", "JsonUtilities" }); PrivateDependencyModuleNames.AddRange(new string[] { }); // Uncomment if you are using Slate UI // PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" }); // Uncomment if you are using online features // PrivateDependencyModuleNames.Add("OnlineSubsystem"); // To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true } } また、Engine.iniの末尾にHTTPリク エス トの設定を記載しておきます。 Engine.ini(クリックで表示) [HTTP] HttpTimeout=60 HttpConnectionTimeout=5 HttpReceiveTimeout=-1 HttpSendTimeout=-1 HttpMaxConnectionsPerServer=32 bEnableHttp=true bUseNullHttp=false HttpDelayTime=0.1 上記が終わったら、HTTPActor.hクラスを変更します。 筆者はEpicのcommunityを参考に#include "CoreMinimal.h"を コメントアウト しました。 後ほどブループリント上で呼び出すため、HTTPリク エス トを実行するHttpMethodはUFUNCTIONを、結果を格納する変数である outputStringにはUPROPERTYを設定しておきます。 HTTPActor.h(クリックで表示) // Fill out your copyright notice in the Description page of Project Settings. #pragma once // #include "CoreMinimal.h" #include "GameFramework/Actor.h" #include "Http.h" #include "HTTPActor.generated.h" UCLASS() class TECHBLOG9_API AHTTPActor : public AActor { GENERATED_BODY() public: // Sets default values for this actor's properties AHTTPActor(); protected: // Called when the game starts or when spawned virtual void BeginPlay() override; public: // Called every frame virtual void Tick(float DeltaTime) override; FHttpModule* Http; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Default) FString outputString; // HTTP通信 UFUNCTION(BlueprintCallable, Category = "Http") void HttpMethod(FString query, FString dir_name); // レスポンス後のイベント処理 void OnResponseReceived(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful); }; 最後に、HTTPActor.cppに具体的な処理内容を記述します。 手順①で実装した API の仕様に合わせ、dir_name(インデックスのフォルダ名)とquery(質問)の2つを引数としてHTTPリク エス トを送り、返ってきた Json の「text」をoutputStringに格納する処理を追加しました。 HTTPActor.cpp(クリックで表示) // Fill out your copyright notice in the Description page of Project Settings. #include "HTTPActor.h" // Sets default values AHTTPActor::AHTTPActor() { // Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it. PrimaryActorTick.bCanEverTick = true; Http = &FHttpModule::Get(); } // Called when the game starts or when spawned void AHTTPActor::BeginPlay() { Super::BeginPlay(); } // Called every frame void AHTTPActor::Tick(float DeltaTime) { Super::Tick(DeltaTime); } void AHTTPActor::HttpMethod(FString query, FString dir_name) { // Jsonデータ作成 TSharedPtr<FJsonObject> JsonObject = MakeShareable(new FJsonObject); JsonObject->SetStringField("dir_name", dir_name); JsonObject->SetStringField("query", query); // OutputStringに Json 格納 FString OutputString; TSharedRef<TJsonWriter > JsonWriter = TJsonWriterFactory<>::Create(&OutputString); FJsonSerializer::Serialize(JsonObject.ToSharedRef(), JsonWriter); // HTTPリク エス ト TSharedRef Request = Http->CreateRequest(); Request->OnProcessRequestComplete().BindUObject(this, &AHTTPActor::OnResponseReceived); Request->SetURL(" http://localhost:8000/search "); Request->SetVerb("POST"); Request->SetHeader(TEXT("User-Agent"), "X-UnrealEngine-Agent"); Request->SetHeader("Content-Type", TEXT("application/ json ")); Request->SetContentAsString(OutputString); Request->ProcessRequest(); } void AHTTPActor::OnResponseReceived(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful) { TSharedPtr<FJsonObject> JsonObject; TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString()); if (FJsonSerializer::Deserialize(Reader, JsonObject)) { // Json からtextを抽出 outputString = JsonObject->GetStringField("text"); } } 上記の変更が終わったらbuildします。 筆者は VS Code で編集していたため、Launch<project_name>Editor(development)でbuildしました。 Unreal Engine が起動され、projectが表示されれば C++ クラスの作成は完了です。 4.3 作成したクラスをブループリント化 最後に、作成した C++ クラスをブループリントから呼び出すため、Blueprintクラスを作成します。 名前は「BP_HTTPActor」としました。 作成した「BP_HTTPActor」をワールドに配置すれば完了です。 手順⑤. 質問送信時の処理を実装 ここまででオブジェクトの準備、GPTの準備、UIの準備が全て終わりました。 最後に、Brueprint上ですべてを繋ぎこむ処理を「BP_C_Item_Book」のブループリント上で実装します。 以下が本手順で作成したブループリントの全体像です。 長くて見づらいため、それぞれ分けて解説します。(見やすさのため、拡大時にレイアウトを調整しています) 3.1で作成したOnclickイベント(チャットの送信ボタン押下)を起点としてブループリントを作成しました。 5.1 「送信」ボタンが押されたとき、送信した内容を表示する 送信ボタンが押下されると、TextBoxの値を取得して新しい 吹き出し として表示します。 ここでは、3.2.1で作成した自分用の 吹き出し を作成し、TextBoxの値を入れてScroll Boxの中に入れています。 5.2 HTTPリク エス トを送信する 4.3で配置した「BP_HTTPActor」クラスを呼び出し、HTTPMethodを実行します。ユーザーの入力と ウィジェット の持つ変数「WidgetAttributes」の値をGPTの API 側へリク エス トとして送信しています。 このとき、outputStringの変数にGPTのレスポンスが格納されるまでは、Delayとloopを使用してwaitします。 また、HTTPリク エス トを送った後、自身が入力したテキストをクリアする処理を記述しました。 5.3 GPTの返信内容を表示する 5.1と同様、今度はGPT用の 吹き出し を作成し、outputStringの値を入れてScroll Boxの中に格納します。 最後に、outputStringの変数をクリアすれば処理は完了です。 デモ動画 本のオブジェクトに近づくことでタッチが可能になり、タッチすることでUIが表示されます。 さらにUI上で問い合わせると、オブジェクトの関連文章を基に回答できていることを確認できます。 これまでの実装により、ストレスの少ない問い合わせをUE上で実現することができました。 おわりに 今回はチャットUIを用いた、オブジェクトに対する問い合わせを実装しました。 以下、実装を担当した岡崎と若本のそれぞれの所感となります。 岡崎:今回の実装で、ブループリントでのチェンジイベント(オンクリックなど)や、 コリジョン イベントに関する使用方法の基礎的な学習を行えました。今後プロダクトを作成する際、プレイヤー起因で任意の情報を出したり、プレイヤー情報を変更させるといった機能を作成する際に、数多く使うことになる機能を作成することができたため、より応用的な使い方など引き続き調査したいと思います。 若本:UE初学者であり、今回広く Python の実装/ブループリントの実装/ C++ の実装を行いましたが、慣れて以降はストレスレスに開発を行うことができました。今回の実装を発展させ、非同期処理の実装や会話履歴の入れ込み、UIのリッチ化など、さらに作りこむことによってよりよいユーザー体験が得られることが期待できると感じました。 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! 私たちは共に働いていただける仲間を募集しています! みなさまのご応募、お待ちしています! 最新テクノロジー事業企画・推進担当 ◎Web3/メタバース/AI AIソリューション開発エンジニア ◎AIビジネス創出・推進に関われる    株式会社電通総研 新卒採用 執筆: @wakamoto.ryosuke 、レビュー: @yamada.y ( Shodo で執筆されました )
こんにちは、ISID金融ソリューション事業部の孫です。 この記事は、私が Unreal Engine (以下UE)のネットワーク同期(以下 レプリケーション )に関する知識を学んだ知見です。 UEの レプリケーション 機能は、 マルチプレイヤー ゲームの開発において非常に重要なコアな機能です。 Web上に公開されているUEの レプリケーション プログラミングは、現在BluePrintを用いたビジュアルプログラミングが主となっています。 確かにBluePrintは便利で迅速な開発が可能ですが、UEの内部動作ロジックをより深く理解するためには C++ プログラミングが不可欠です。 この記事では、 C++ を使用してシンプルなネットワーク同期のデモを実装する方法を紹介します。このデモの開発を通じて、UEの レプリケーション 機能の実装方法を学ぶことができます。 はじめに UEの レプリケーション 部分について触れると、Dedicated Serverという概念をまず理解する必要があります。 ゲームネットワーク アーキテクチャ においてDedicated Serverが導入された背景については、 金融ソリューション事業部の山下さんの記事 を参照していただければと思います。 UEのDedicated Serverについて、UEのクライアントコードとサーバーコードは一体であることが特徴です。 通常、一般的なフロントエンドとバックエンドの分離とは異なり、UEではクライアントとサーバーが同じプロジェクト内に存在します。このため、クライアントとサーバーのコードは混在していることになります。 ※コードの間で以下のマクロを使ってサーバーコードとクライアントコードの区別が可能: WITH_EDITOR : コードがエディタ環境で動作しているときにTrueになります。 UE_SERVER : コードがサーバー環境で動作しているときにTrueになります。 UE_CLIENT : コードがクライアント環境で動作しているときにTrueになります。 Dedicated Serverが必要な理由は、C/S(クライアント/サーバー)モードではサーバーがクライアントの業務も担当するため、運用負荷が高くなるからです。Dedicated Serverの導入により、クライアントとサーバーの役割が分離され、負荷を軽減できます。 Dedicated Serverは、UEが FPS の同期問題を解決するために設計された専用のサーバーです。また、Dedicated ServerはEpicが開発した特別な最適化されたネットワーク プロトコル を使用しており、高性能な同期(遅延問題の解決)を実現できます。 Dedicated Serverの構築方法については、以前の 記事 を参照してください。 開発環境/ツール Unreal Engine 5.2.0 Windows10 21H2 x64 RAM 16GM, SSD 1TB NVIDIA GeForce GTX 3080 Visual Studio 2019 version 16.11.21(以下VS2019) それでは、デモの制作を開始しましょう。以下の手順で進めていきます。 ※デモはUEのサードパーソンテンプレートの上で レプリケーション 機能を実装 UEのネットワーク知識とActorの権限確認(ユーザーネーム表示用キャ ラク ターの作成含め) ユーザー名入力画面の作成 ユーザーネームの レプリケーション 実装 Dedicated Server側の実装 デモの確認 このような手順でデモの制作を進めていくと、ユーザーネームを持つキャ ラク ターを生成し、それを全てのクライアントで同期できます。 1.UEのネットワーク知識とActor権限の確認 本番の作成を開始する前に、まずは2点の前提知識を説明します。 UEのネットワークモデル UEのネットワークモデルでは、Actorの レプリケーション を通じてゲームオブジェクトの状態をクライアント間で同期します。これにより、各クライアントが一貫したゲームワールドを見ることができます。 UEのネットワークモデルには、いくつかのキーポイントと概念があります: Actor Replication(アクターレプリケーション) : Unreal Engine では、各ゲームオブジェクトはActorと呼ばれます。サーバー内のActorの状態はクライアントに レプリケーション (複製)される可能性があり、これを「 レプリケーション 」と呼びます。どのActorが レプリケーション され、どのように レプリケーション されるかは、開発者が特定の属性と関数を設定することで決定されます。 State Synchronization(状態同期) :サーバーは自身の状態をネットワークを通じて各クライアントに送信し、全てのクライアントが一貫したゲームワールドを見ることができるようにします。この過程を状態同期と呼びます。これがサーバーコンテンツを各クライアントに分散する必要性の理由です。この過程がなければ、クライアントは古いか、または一貫性のないゲームワールドの状態を見ることになりゲーム体験が低下します。 Client Prediction(クライアント予測) :ネットワークの遅延がゲーム体験に影響を与えるのを減らすために、クライアントは「クライアント予測」と呼ばれる技術を使用します。つまり、サーバーからの応答が到着する前に、クライアントはあらかじめいくつかのアクションを実行します。サーバーからの応答を受け取ったら、クライアントは自身の状態を調整してサーバーの状態に一致させます。 Lag Compensation(ラグ補償) :これはネットワーク遅延の影響を減らす別のテクニックです。サーバーは、クライアントがリク エス トを発行した時点のゲーム状態に戻って、その状態でリク エス トされた操作を実行します。 これらの コンポーネント やテクニックを組み合わせることで、UEのネットワークモデルは安定性と効率性を持ち、多人数プレイにおける一貫したゲーム体験を実現します。 各 コンポーネント は重要な役割を果たしますが、その中でも核心的な概念は「状態同期」です。状態同期は、すべてのプレイヤーが一貫したゲームワールドを見ることを保証し、各自異なる視覚体験やインタ ラク ション体験が生じることを防ぎます。 Actorの所有権-ROLE クライアントとサーバーの間に区別がある以上、Actorの所有権においてもクライアントとサーバーの区別があることは当然です。 UEでは、Actorの制御権限を3つのカテゴリに分けています。それらは以下のとおりです: ROLE_None :特定の制御権限に属さない状態を表します。 ROLE_Authority :サーバー側でActorの制御権を持つことを示します。 ROLE_AutonomousProxy :クライアント側でローカルなActorの制御権を持つことを示します。 ROLE_SimulatedProxy :他クライアントがActor制御権を持つことを示します。 これらの三つの属性はUEがActorを設計する際に、Actorに固有属性として設計されています。これはActorがどこに存在するかを判断するために使われます。 UEでは、サーバーのコードとクライアントのコードが一体化しているため、Actorがこの属性を持つことは非常に必要です。 その辺の権限関係をテストしてみましょう。 テンプレートのCharacterにRendertextを追加します。 xxxCharacter.h #include "Components/TextRenderComponent.h" ... public: UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = playername, meta = (AllowPrivateAccess = "true")) class UTextRenderComponent* playerNameTag; ... xxxCharacter.cpp #include "Components/SkeletalMeshComponent.h" // コンストラクタ関数にキャラクターのSkeletalメッシュ配下にTextRenderComponent追加 xxxCharacter::xxxCharacter() { ... // Create a Text Component playerNameTag = CreateDefaultSubobject<UTextRenderComponent>(TEXT("playerName")); USkeletalMeshComponent* SkeletalMesh = GetMesh(); playerNameTag->SetupAttachment(SkeletalMesh); playerNameTag->SetText(FText::FromString("test")); // Set Component Location Rotation playerNameTag->SetHorizontalAlignment(EHTA_Center); playerNameTag->SetRelativeLocation(FVector(0.0f, 0.0f, 180.0f)); playerNameTag->SetRelativeRotation(FRotator(0.0f, 90.0f, 0.0f)); ... 制御Roleを表示します。 xxxCharacter.cpp void Atest_DEServerCharacter::BeginPlay() { if (GetLocalRole() == ROLE_Authority) { playerNameTag->SetText(FText::FromString("ROLE_Authority")); UE_LOG(LogTemp, Warning, TEXT("This Actor is on the server.")); } else if (GetLocalRole() == ROLE_AutonomousProxy) { playerNameTag->SetText(FText::FromString("ROLE_AutonomousProxy")); UE_LOG(LogTemp, Warning, TEXT("This Actor is on the owning client.")); } else if (GetLocalRole()== ROLE_SimulatedProxy) { playerNameTag->SetText(FText::FromString("ROLE_SimulatedProxy")); UE_LOG(LogTemp, Warning, TEXT("Other Client Actor is ROLE_SimulatedProxy.")); } else { playerNameTag->SetText(FText::FromString("ROLE_None")); UE_LOG(LogTemp, Warning, TEXT("This Actor is on a non-owning client.")); } } 権限Roleを確認します。 サーバーのウィンドウでは、すべてのキャ ラク ターが「ROLE_Authority」と表示されていることがわかります。 それに対して二つのクライアントのウィンドウでは、自分が制御しているキャ ラク ターだけ「ROLE_AutonomousProxy」と表示され、他のすべては「ROLE_SimulatedProxy」と表示されています。 その中にはサーバーが生成したキャ ラク ターも含まれていますが、このクライアントにとってはそれも他のエンドのActorに属するものとなります。 次に、ステップ バイス テップで レプリケーション デモを作成します。 2.ユーザー名入力画面の作成 入力用 Widget UIの作成 Content Browserで Content フォルダを開き、右側の空白部分で右クリックして User Interface -> Widget Blueprintを選択してUIを作成します。 新しく作成したBlueprintをダブルクリックして開き、以下の画像のように「ゲーム開始Button」「ユーザー名入力のEditableText」とタイトル表示の「TextBlock ウィジェット 」を追加します。 Widget UIの親Classファイルの作成 Content Browser で C++Classes フォルダを開き、右側の空白部分で右クリックし New C++ Class -> UserWidget を選択します。 新しく作成したクラスファイルが自動的に Visual Studio で開かれます。 このクラスがBlueprintのUIを制御するために、Blueprintの親クラスを新しく作成したクラスに変更します。 UIのBlueprintをダブルクリックして開き、 File -> Reparent Blueprint をクリックし、表示されるダイアログで新しく作成したクラスを選択します。 ユーザー名の取得コードの実装 制御する ウィジェット の定義を追加します。 定義に UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) を追加して、Blueprint Widget 内の対応する ウィジェット にバインドできるようにします。 //LoginHUDWidget.h UCLASS() class TEST_DESERVER_API ULoginHUDWidget : public UUserWidget { GENERATED_BODY() public: void NativePreConstruct(); UFUNCTION() void OnPlayGameButtonClicked(); UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UEditableText* inputName; UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UTextBlock* statusLabel; UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UTextBlock* playLabel; UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UButton* playBtn; }; コンスト ラク タ関数で ウィジェット を初期化します。 Button ウィジェット には OnPlayGameButtonClicked という処理関数を追加します。 //LoginHUDWidget.cpp void ULoginHUDWidget::NativePreConstruct() { Super::NativePreConstruct(); inputName->SetHintText(FText::FromString("Please input your name")); statusLabel->SetText(FText::FromString("Test Replication Demo")); playLabel->SetText(FText::FromString("Play")); FScriptDelegate StartPlayDelegate; StartPlayDelegate.BindUFunction(this, "OnPlayGameButtonClicked"); playBtn->OnClicked.Add(StartPlayDelegate); }; ゲーム開始ボタンがクリックされた後の処理ロジックを追加します。 UGameplayStatics::OpenLevel 関数を使用してDedicated Serverに接続します。 ユーザーが入力したプレイヤー名はOptionsを通じてDedicated Serverに渡されます。 void ULoginHUDWidget::OnPlayGameButtonClicked() { FString NickName = inputName->GetText().ToString(); FString LevelName = "127.0.0.1:7777"; FString Options = FString::Printf(TEXT("?NickName=%s"), *NickName); UGameplayStatics::OpenLevel(GetWorld(), FName(*LevelName), false, Options); } GameModeで Widget コンポーネント をロードします。 //xxxGameMode.h protected: UPROPERTY(EditAnywhere, Category = "UI") TSubclassOf<UUserWidget> LoginWidgetClass; private: UPROPERTY() ULoginHUDWidget* loginWidget; //xxxGameMode.cpp AxxxGameMode::AxxxGameMode() { static ConstructorHelpers::FClassFinder<UUserWidget> LoginWidgetObj(TEXT("/Game/UI/LoginHUD_UI")); LoginWidgetClass = LoginWidgetObj.Class; }; void AxxxGameMode::BeginPlay() { Super::BeginPlay(); APlayerController* PlayerController = GetWorld()->GetFirstPlayerController(); if (PlayerController != nullptr) { PlayerController->bShowMouseCursor = true; } if (LoginWidgetClass != nullptr) { UUserWidget* loginWidget = CreateWidget<UUserWidget>(GetWorld(), LoginWidgetClass); if (loginWidget != nullptr) { loginWidget->AddToViewport(); } } } 3.ユーザーネームの レプリケーション 実装 クライアントの属性が変更されたときに、その属性値が他のクライアントに同期するためには、以下の2点を覚えておく必要があります: ① 属性のReplicationはReplicated or ReplicatedUsingに設定すべきです ② 属性を変更するコードはDedicated Server上で実行されます Actorの レプリケーション ReplicationはUObjectから派生した任意クラスの変数の固有属性で、この変数がネットワーク同期を許可するかどうかを指定します。 ブループリントでは、以下の3つの項目がReplication属性に対して設定可能です: None :ネットワーク同期を許可しません。 Replication :ネットワーク同期を許可します。 RepNotify :属性はネットワーク同期を許可し、さらにコールバック関数にバインドします。属性が変化すると、このコールバックが呼び出されます。ブループリントでは、このコールバック関数はFUNCTION内に自動的に作成され、OnRep_で始まり属性名で終わるようになっています。例えば、pos属性のコールバック関数はOnRep_posとなります。 C++ でこの部分は、 APlayerState クラスで実装されます。 APlayerState は Unreal Engine のクラスであり、各プレイヤーに関連するゲーム情報を格納および管理するために使用されます。 この情報は、プレイヤーが現在のシーンにいるかどうかに関係なく、通常はゲームセッション全体で永続的です。この設計により、 APlayerState はゲームセッション全体のレベル内でプレイヤー情報を格納する理想的な場所となります。 ※ 注意 : APlayerState はプレイヤーの入力やゲームワールド内での状態(位置、速度、アニメーションの状態など)を格納するためのものではありません。これらの情報は APlayerController に格納する必要があります。 該当する C++ コードの例は以下のようになります。 # ネットワーク同期を許可しません UPROPERTY() # ネットワーク同期を許可します UPROPERTY(Replicated) # 属性はネットワーク同期を許可し、さらにコールバック関数にバインドします UPROPERTY(ReplicatedUsing=OnRep_xxx) ユーザーネームの レプリケーション 実装 Playstateのサブクラスを新規作成します。 Widget Classを作成したのと同じ手順で、 C++ Classesフォルダで右クリックし、 New C++ Class -> playerState を選択します。 作成が完了すると、 Visual Studio が自動的に新しいクラスファイルを開きます。 Replicatedとして定義します。 DOREPLIFETIME Unreal Engine のネットワークプログラミングにおけるマクロであり、特定のプロパティがネットワーク上で複製可能であることを設定するために使用されます。 //playerstate.h public: UPROPERTY(Replicated) FString NickName; virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override; //playerstate.cpp void AMetaPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(AMetaPlayerState, NickName); } GameModeのコンスト ラク タ関数に PlayerState をロードします。 AxxxGameMode::AxxxGameMode() { PlayerStateClass = AMetaPlayerState::StaticClass(); }; サーバーからの更新メッセージを受け取り、キャ ラク ターに値を割り当てます。 コン トロール しているキャ ラク ターについて、上記で定義したNickNameが変更された場合、 OnRep_PlayerState 関数が実行されます。 OnRep_PlayerState 関数は APlayerState クラスに定義されており、それを継承する必要があります。 //xxxCharacter.h private: virtual void OnRep_PlayerState() override; //xxxCharacter.cpp void AxxxCharacter::OnRep_PlayerState() { Super::OnRep_PlayerState(); APlayerState* OwningPlayerState = GetPlayerState(); if (OwningPlayerState != nullptr) { AMetaPlayerState* MetaPlayerState = Cast<AMetaPlayerState>(OwningPlayerState); if (MetaPlayerState != nullptr) { FString NickName = MetaPlayerState->NickName; if (NickName.Len() > 0 ) { playerNameTag->SetText(FText::FromString(NickName)); } } } } 4.Dedicated Server側の実装 UEの AGameModeBase および AGameMode クラスは、ゲームの基本ルールとロジックを定義するために使用されます。 以下に、これらのクラスでいくつかの重要なライフサイクル関数と、それらが通常どのような役割を果たすかを示します。 InitGame() :この関数はサーバーが起動し、すべてのオブジェクトがロードされ、ゲームがまだ実行されていない状態で呼び出されます。ここで変数や状態を初期化できます。 PreLogin() :これはクライアントが接続を許可される前にサーバーで呼び出される関数です。プレイヤーの資格情報(例:ユーザー名やパスワードの確認)を検証したり、プレイヤーの接続を他の形式で事前検証したりするために使用できます。検証が失敗した場合、ここでプレイヤーの接続を拒否できます。 PostLogin() :プレイヤーが正常に接続され、検証された後、PostLogin()関数が呼び出されます。ここでは、プレイヤーがゲームに参加した後すぐに実行する必要のあるコードを実行できます。例えば、歓迎メッセージを送信したり、プレイヤーのゲームデータを初期化したりできます。 InitNewPlayer() :この関数はプレイヤーがサーバーに接続して初期化されたときに呼び出されます。この関数では、「プレイヤーの属性の初期化」「プレイヤーのチームの割り当て」「新しいプレイヤーに必要なゲーム情報の送信」など、多くのタスクを実行できます。 BeginPlay() :この関数はゲームの開始時に呼び出されます。ゲーム開始時に実行する必要があるコードをここで実行できます。 Logout() :プレイヤーがゲームから退出するときにこの関数が呼び出されます。ここでは、プレイヤーがゲームから退出する際に実行する必要のあるコード(例:プレイヤーのゲームデータの保存や、プレイヤーの退出メッセージの送信など)を実行できます。 これらの関数は最も一般的に使用され、ゲームの異なる段階で何を実行するかをサーバーサイドで処理するために使用されます。    これらの関数により、ゲームのロジックや要件に合わせて特定のコードを適切なタイミングで実行できます。 前述のように、同期を実現するには2つの条件を満たす必要があります。 「② 属性の変更コードはDedicated Server上で実行される」 という条件を満たすために、上記の関数の中で、特に InitNewPlayer() 関数を選択する必要があります。 なぜなら、この関数はプレイヤーの PlayState 初期化が行われるタイミングですから。 //.xxxGameMode.h virtual FString InitNewPlayer(APlayerController* NewPlayerController, const FUniqueNetIdRepl& UniqueId, const FString& Options, const FString& Portal) override; //.xxxGameMode.cpp FString xxxServerGameMode::InitNewPlayer(APlayerController* NewPlayerController, const FUniqueNetIdRepl& UniqueId, const FString& Options, const FString& Portal) { FString InitializedString = Super::InitNewPlayer(NewPlayerController, UniqueId, Options, Portal); const FString& nickName = UGameplayStatics::ParseOption(Options, "NickName"); if (NewPlayerController != nullptr) { APlayerState* PlayerState = NewPlayerController->PlayerState; if (PlayerState != nullptr) { AMetaPlayerState* ServerPlayerState = Cast<AMetaPlayerState>(PlayerState); if (ServerPlayerState) { ServerPlayerState->NickName = nickName; } } } return InitializedString; } 5.デモの確認 ここまでで、ユーザーネームの レプリケーション に関する実装が全部完了しました! Dedicated Serverをパッケージ化して試してみましょう。確認ポイントは以下となります: 各クライアントでユーザーがユーザーネームを入力し、ゲームが開始できること 各クライアントで対応するキャ ラク ターの名前が表示されること 上記の確認が取れましたら、いわゆるネットワーク上での状態同期が成功し、すべてのクライアントが一貫したゲーム世界を見ることができるようになりました! ※パッケージング手順については Amazon GameLift × Unreal Engines 5 でオンラインマルチプレイゲームを作る の記事を参照してください。 注意事項 属性の同期を実装する際には、以下の点を注意してください。 それは、キャ ラク ターモデルの変更をAPlayerStateクラスに実装しないということです。 筆者がコードを書く際、最初に APlayerState クラスに以下のようなコールバック関数を書いたことがありました。 void ATestPlayerState::OnRep_NickName() { APlayerController* PC = GetGameInstance()->GetFirstLocalPlayerController(); if (PC && PC->GetPawn()) { AMetaPlayerController* PlayerController = Cast<AMetaPlayerController>(PC); if (PlayerController) { Atest_DEServerCharacter* MyCharacter = Cast<Atest_DEServerCharacter>(PlayerController->GetPawn()); if (MyCharacter) { MyCharacter->playerNameTag->SetText(FText::FromString(NickName)); } } } } 実行結果として、もともとPlayer1を制御していたユーザーが、Player2がログインした後に制御しているキャ ラク ターの表示がPlayer2のユーザー名になってしまうという問題が発生しました。    これは、私が APlayerState の OnRep_NickName 関数でキャ ラク ターを取得し、名前ラベルを変更していたためです。 この関数は、NickNameフィールドがクライアントに複製されたときに呼び出されます。しかし一部の場合では、NickNameフィールドがクライアントに複製された時点では、クライアントが新しいキャ ラク ターの情報をまだ受信していない可能性があります。つまり、GetPawn()関数がnullを返すか、もしくはキャ ラク ターが存在していても既に存在する他のプレイヤーのキャ ラク ターになるかもしれません。 この問題を解決するための方法は、 APlayerState のOnRep関数内でキャ ラク ターを取得し、名前ラベルを変更しないことです。 代わりに、キャ ラク ターのOnRep_PlayerState関数内で、キャ ラク ター自身のPlayerStateを取得しキャ ラク ターの名前ラベルを変更する必要があります。先ほど実装したコードと同様に、キャ ラク ター自身のOnRep_PlayerState関数でこれを行ってください。 終わりに このユーザーネーム属性の レプリケーション デモを通じて、 Unreal Engine のネットワーク同期モデルについて一定の理解を得ることができました。 Unreal Engine は、さまざまなタイプのゲームや仮想現実アプリケーションをサポートする強力な ゲームエンジン です。3Dおよび メタバース の開発領域では、ネットワーク同期、 マルチプレイヤー ゲーム、物理シミュレーション、シーンの レンダリング など、さまざまな技術と応用を探求できます。学習と研究を続けることで、この領域においてより深い理解と高い技術力を身につけることができます。 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 参考文献 https://docs.unrealengine.com/5.2/en-US/networking-overview-for-unreal-engine/ 執筆: @chen.sun 、レビュー: @yamashita.yuki ( Shodo で執筆されました )
こんにちは、ISID金融ソリューション事業部の孫です。 この記事は、私が Unreal Engine (以下UE)のネットワーク同期(以下 レプリケーション )に関する知識を学んだ知見です。 UEの レプリケーション 機能は、 マルチプレイヤー ゲームの開発において非常に重要なコアな機能です。 Web上に公開されているUEの レプリケーション プログラミングは、現在BluePrintを用いたビジュアルプログラミングが主となっています。 確かにBluePrintは便利で迅速な開発が可能ですが、UEの内部動作ロジックをより深く理解するためには C++ プログラミングが不可欠です。 この記事では、 C++ を使用してシンプルなネットワーク同期のデモを実装する方法を紹介します。このデモの開発を通じて、UEの レプリケーション 機能の実装方法を学ぶことができます。 はじめに UEの レプリケーション 部分について触れると、Dedicated Serverという概念をまず理解する必要があります。 ゲームネットワーク アーキテクチャ においてDedicated Serverが導入された背景については、 金融ソリューション事業部の山下さんの記事 を参照していただければと思います。 UEのDedicated Serverについて、UEのクライアントコードとサーバーコードは一体であることが特徴です。 通常、一般的なフロントエンドとバックエンドの分離とは異なり、UEではクライアントとサーバーが同じプロジェクト内に存在します。このため、クライアントとサーバーのコードは混在していることになります。 ※コードの間で以下のマクロを使ってサーバーコードとクライアントコードの区別が可能: WITH_EDITOR : コードがエディタ環境で動作しているときにTrueになります。 UE_SERVER : コードがサーバー環境で動作しているときにTrueになります。 UE_CLIENT : コードがクライアント環境で動作しているときにTrueになります。 Dedicated Serverが必要な理由は、C/S(クライアント/サーバー)モードではサーバーがクライアントの業務も担当するため、運用負荷が高くなるからです。Dedicated Serverの導入により、クライアントとサーバーの役割が分離され、負荷を軽減できます。 Dedicated Serverは、UEが FPS の同期問題を解決するために設計された専用のサーバーです。また、Dedicated ServerはEpicが開発した特別な最適化されたネットワーク プロトコル を使用しており、高性能な同期(遅延問題の解決)を実現できます。 Dedicated Serverの構築方法については、以前の 記事 を参照してください。 開発環境/ツール Unreal Engine 5.2.0 Windows10 21H2 x64 RAM 16GM, SSD 1TB NVIDIA GeForce GTX 3080 Visual Studio 2019 version 16.11.21(以下VS2019) それでは、デモの制作を開始しましょう。以下の手順で進めていきます。 ※デモはUEのサードパーソンテンプレートの上で レプリケーション 機能を実装 UEのネットワーク知識とActorの権限確認(ユーザーネーム表示用キャ ラク ターの作成含め) ユーザー名入力画面の作成 ユーザーネームの レプリケーション 実装 Dedicated Server側の実装 デモの確認 このような手順でデモの制作を進めていくと、ユーザーネームを持つキャ ラク ターを生成し、それを全てのクライアントで同期できます。 1.UEのネットワーク知識とActor権限の確認 本番の作成を開始する前に、まずは2点の前提知識を説明します。 UEのネットワークモデル UEのネットワークモデルでは、Actorの レプリケーション を通じてゲームオブジェクトの状態をクライアント間で同期します。これにより、各クライアントが一貫したゲームワールドを見ることができます。 UEのネットワークモデルには、いくつかのキーポイントと概念があります: Actor Replication(アクターレプリケーション) : Unreal Engine では、各ゲームオブジェクトはActorと呼ばれます。サーバー内のActorの状態はクライアントに レプリケーション (複製)される可能性があり、これを「 レプリケーション 」と呼びます。どのActorが レプリケーション され、どのように レプリケーション されるかは、開発者が特定の属性と関数を設定することで決定されます。 State Synchronization(状態同期) :サーバーは自身の状態をネットワークを通じて各クライアントに送信し、全てのクライアントが一貫したゲームワールドを見ることができるようにします。この過程を状態同期と呼びます。これがサーバーコンテンツを各クライアントに分散する必要性の理由です。この過程がなければ、クライアントは古いか、または一貫性のないゲームワールドの状態を見ることになりゲーム体験が低下します。 Client Prediction(クライアント予測) :ネットワークの遅延がゲーム体験に影響を与えるのを減らすために、クライアントは「クライアント予測」と呼ばれる技術を使用します。つまり、サーバーからの応答が到着する前に、クライアントはあらかじめいくつかのアクションを実行します。サーバーからの応答を受け取ったら、クライアントは自身の状態を調整してサーバーの状態に一致させます。 Lag Compensation(ラグ補償) :これはネットワーク遅延の影響を減らす別のテクニックです。サーバーは、クライアントがリク エス トを発行した時点のゲーム状態に戻って、その状態でリク エス トされた操作を実行します。 これらの コンポーネント やテクニックを組み合わせることで、UEのネットワークモデルは安定性と効率性を持ち、多人数プレイにおける一貫したゲーム体験を実現します。 各 コンポーネント は重要な役割を果たしますが、その中でも核心的な概念は「状態同期」です。状態同期は、すべてのプレイヤーが一貫したゲームワールドを見ることを保証し、各自異なる視覚体験やインタ ラク ション体験が生じることを防ぎます。 Actorの所有権-ROLE クライアントとサーバーの間に区別がある以上、Actorの所有権においてもクライアントとサーバーの区別があることは当然です。 UEでは、Actorの制御権限を3つのカテゴリに分けています。それらは以下のとおりです: ROLE_None :特定の制御権限に属さない状態を表します。 ROLE_Authority :サーバー側でActorの制御権を持つことを示します。 ROLE_AutonomousProxy :クライアント側でローカルなActorの制御権を持つことを示します。 ROLE_SimulatedProxy :他クライアントがActor制御権を持つことを示します。 これらの三つの属性はUEがActorを設計する際に、Actorに固有属性として設計されています。これはActorがどこに存在するかを判断するために使われます。 UEでは、サーバーのコードとクライアントのコードが一体化しているため、Actorがこの属性を持つことは非常に必要です。 その辺の権限関係をテストしてみましょう。 テンプレートのCharacterにRendertextを追加します。 xxxCharacter.h #include "Components/TextRenderComponent.h" ... public: UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = playername, meta = (AllowPrivateAccess = "true")) class UTextRenderComponent* playerNameTag; ... xxxCharacter.cpp #include "Components/SkeletalMeshComponent.h" // コンストラクタ関数にキャラクターのSkeletalメッシュ配下にTextRenderComponent追加 xxxCharacter::xxxCharacter() { ... // Create a Text Component playerNameTag = CreateDefaultSubobject<UTextRenderComponent>(TEXT("playerName")); USkeletalMeshComponent* SkeletalMesh = GetMesh(); playerNameTag->SetupAttachment(SkeletalMesh); playerNameTag->SetText(FText::FromString("test")); // Set Component Location Rotation playerNameTag->SetHorizontalAlignment(EHTA_Center); playerNameTag->SetRelativeLocation(FVector(0.0f, 0.0f, 180.0f)); playerNameTag->SetRelativeRotation(FRotator(0.0f, 90.0f, 0.0f)); ... 制御Roleを表示します。 xxxCharacter.cpp void Atest_DEServerCharacter::BeginPlay() { if (GetLocalRole() == ROLE_Authority) { playerNameTag->SetText(FText::FromString("ROLE_Authority")); UE_LOG(LogTemp, Warning, TEXT("This Actor is on the server.")); } else if (GetLocalRole() == ROLE_AutonomousProxy) { playerNameTag->SetText(FText::FromString("ROLE_AutonomousProxy")); UE_LOG(LogTemp, Warning, TEXT("This Actor is on the owning client.")); } else if (GetLocalRole()== ROLE_SimulatedProxy) { playerNameTag->SetText(FText::FromString("ROLE_SimulatedProxy")); UE_LOG(LogTemp, Warning, TEXT("Other Client Actor is ROLE_SimulatedProxy.")); } else { playerNameTag->SetText(FText::FromString("ROLE_None")); UE_LOG(LogTemp, Warning, TEXT("This Actor is on a non-owning client.")); } } 権限Roleを確認します。 サーバーのウィンドウでは、すべてのキャ ラク ターが「ROLE_Authority」と表示されていることがわかります。 それに対して二つのクライアントのウィンドウでは、自分が制御しているキャ ラク ターだけ「ROLE_AutonomousProxy」と表示され、他のすべては「ROLE_SimulatedProxy」と表示されています。 その中にはサーバーが生成したキャ ラク ターも含まれていますが、このクライアントにとってはそれも他のエンドのActorに属するものとなります。 次に、ステップ バイス テップで レプリケーション デモを作成します。 2.ユーザー名入力画面の作成 入力用 Widget UIの作成 Content Browserで Content フォルダを開き、右側の空白部分で右クリックして User Interface -> Widget Blueprintを選択してUIを作成します。 新しく作成したBlueprintをダブルクリックして開き、以下の画像のように「ゲーム開始Button」「ユーザー名入力のEditableText」とタイトル表示の「TextBlock ウィジェット 」を追加します。 Widget UIの親Classファイルの作成 Content Browser で C++Classes フォルダを開き、右側の空白部分で右クリックし New C++ Class -> UserWidget を選択します。 新しく作成したクラスファイルが自動的に Visual Studio で開かれます。 このクラスがBlueprintのUIを制御するために、Blueprintの親クラスを新しく作成したクラスに変更します。 UIのBlueprintをダブルクリックして開き、 File -> Reparent Blueprint をクリックし、表示されるダイアログで新しく作成したクラスを選択します。 ユーザー名の取得コードの実装 制御する ウィジェット の定義を追加します。 定義に UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) を追加して、Blueprint Widget 内の対応する ウィジェット にバインドできるようにします。 //LoginHUDWidget.h UCLASS() class TEST_DESERVER_API ULoginHUDWidget : public UUserWidget { GENERATED_BODY() public: void NativePreConstruct(); UFUNCTION() void OnPlayGameButtonClicked(); UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UEditableText* inputName; UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UTextBlock* statusLabel; UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UTextBlock* playLabel; UPROPERTY(EditAnywhere, BlueprintReadWrite, meta = (BindWidget)) class UButton* playBtn; }; コンスト ラク タ関数で ウィジェット を初期化します。 Button ウィジェット には OnPlayGameButtonClicked という処理関数を追加します。 //LoginHUDWidget.cpp void ULoginHUDWidget::NativePreConstruct() { Super::NativePreConstruct(); inputName->SetHintText(FText::FromString("Please input your name")); statusLabel->SetText(FText::FromString("Test Replication Demo")); playLabel->SetText(FText::FromString("Play")); FScriptDelegate StartPlayDelegate; StartPlayDelegate.BindUFunction(this, "OnPlayGameButtonClicked"); playBtn->OnClicked.Add(StartPlayDelegate); }; ゲーム開始ボタンがクリックされた後の処理ロジックを追加します。 UGameplayStatics::OpenLevel 関数を使用してDedicated Serverに接続します。 ユーザーが入力したプレイヤー名はOptionsを通じてDedicated Serverに渡されます。 void ULoginHUDWidget::OnPlayGameButtonClicked() { FString NickName = inputName->GetText().ToString(); FString LevelName = "127.0.0.1:7777"; FString Options = FString::Printf(TEXT("?NickName=%s"), *NickName); UGameplayStatics::OpenLevel(GetWorld(), FName(*LevelName), false, Options); } GameModeで Widget コンポーネント をロードします。 //xxxGameMode.h protected: UPROPERTY(EditAnywhere, Category = "UI") TSubclassOf<UUserWidget> LoginWidgetClass; private: UPROPERTY() ULoginHUDWidget* loginWidget; //xxxGameMode.cpp AxxxGameMode::AxxxGameMode() { static ConstructorHelpers::FClassFinder<UUserWidget> LoginWidgetObj(TEXT("/Game/UI/LoginHUD_UI")); LoginWidgetClass = LoginWidgetObj.Class; }; void AxxxGameMode::BeginPlay() { Super::BeginPlay(); APlayerController* PlayerController = GetWorld()->GetFirstPlayerController(); if (PlayerController != nullptr) { PlayerController->bShowMouseCursor = true; } if (LoginWidgetClass != nullptr) { UUserWidget* loginWidget = CreateWidget<UUserWidget>(GetWorld(), LoginWidgetClass); if (loginWidget != nullptr) { loginWidget->AddToViewport(); } } } 3.ユーザーネームの レプリケーション 実装 クライアントの属性が変更されたときに、その属性値が他のクライアントに同期するためには、以下の2点を覚えておく必要があります: ① 属性のReplicationはReplicated or ReplicatedUsingに設定すべきです ② 属性を変更するコードはDedicated Server上で実行されます Actorの レプリケーション ReplicationはUObjectから派生した任意クラスの変数の固有属性で、この変数がネットワーク同期を許可するかどうかを指定します。 ブループリントでは、以下の3つの項目がReplication属性に対して設定可能です: None :ネットワーク同期を許可しません。 Replication :ネットワーク同期を許可します。 RepNotify :属性はネットワーク同期を許可し、さらにコールバック関数にバインドします。属性が変化すると、このコールバックが呼び出されます。ブループリントでは、このコールバック関数はFUNCTION内に自動的に作成され、OnRep_で始まり属性名で終わるようになっています。例えば、pos属性のコールバック関数はOnRep_posとなります。 C++ でこの部分は、 APlayerState クラスで実装されます。 APlayerState は Unreal Engine のクラスであり、各プレイヤーに関連するゲーム情報を格納および管理するために使用されます。 この情報は、プレイヤーが現在のシーンにいるかどうかに関係なく、通常はゲームセッション全体で永続的です。この設計により、 APlayerState はゲームセッション全体のレベル内でプレイヤー情報を格納する理想的な場所となります。 ※ 注意 : APlayerState はプレイヤーの入力やゲームワールド内での状態(位置、速度、アニメーションの状態など)を格納するためのものではありません。これらの情報は APlayerController に格納する必要があります。 該当する C++ コードの例は以下のようになります。 # ネットワーク同期を許可しません UPROPERTY() # ネットワーク同期を許可します UPROPERTY(Replicated) # 属性はネットワーク同期を許可し、さらにコールバック関数にバインドします UPROPERTY(ReplicatedUsing=OnRep_xxx) ユーザーネームの レプリケーション 実装 Playstateのサブクラスを新規作成します。 Widget Classを作成したのと同じ手順で、 C++ Classesフォルダで右クリックし、 New C++ Class -> playerState を選択します。 作成が完了すると、 Visual Studio が自動的に新しいクラスファイルを開きます。 Replicatedとして定義します。 DOREPLIFETIME Unreal Engine のネットワークプログラミングにおけるマクロであり、特定のプロパティがネットワーク上で複製可能であることを設定するために使用されます。 //playerstate.h public: UPROPERTY(Replicated) FString NickName; virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override; //playerstate.cpp void AMetaPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(AMetaPlayerState, NickName); } GameModeのコンスト ラク タ関数に PlayerState をロードします。 AxxxGameMode::AxxxGameMode() { PlayerStateClass = AMetaPlayerState::StaticClass(); }; サーバーからの更新メッセージを受け取り、キャ ラク ターに値を割り当てます。 コン トロール しているキャ ラク ターについて、上記で定義したNickNameが変更された場合、 OnRep_PlayerState 関数が実行されます。 OnRep_PlayerState 関数は APlayerState クラスに定義されており、それを継承する必要があります。 //xxxCharacter.h private: virtual void OnRep_PlayerState() override; //xxxCharacter.cpp void AxxxCharacter::OnRep_PlayerState() { Super::OnRep_PlayerState(); APlayerState* OwningPlayerState = GetPlayerState(); if (OwningPlayerState != nullptr) { AMetaPlayerState* MetaPlayerState = Cast<AMetaPlayerState>(OwningPlayerState); if (MetaPlayerState != nullptr) { FString NickName = MetaPlayerState->NickName; if (NickName.Len() > 0 ) { playerNameTag->SetText(FText::FromString(NickName)); } } } } 4.Dedicated Server側の実装 UEの AGameModeBase および AGameMode クラスは、ゲームの基本ルールとロジックを定義するために使用されます。 以下に、これらのクラスでいくつかの重要なライフサイクル関数と、それらが通常どのような役割を果たすかを示します。 InitGame() :この関数はサーバーが起動し、すべてのオブジェクトがロードされ、ゲームがまだ実行されていない状態で呼び出されます。ここで変数や状態を初期化できます。 PreLogin() :これはクライアントが接続を許可される前にサーバーで呼び出される関数です。プレイヤーの資格情報(例:ユーザー名やパスワードの確認)を検証したり、プレイヤーの接続を他の形式で事前検証したりするために使用できます。検証が失敗した場合、ここでプレイヤーの接続を拒否できます。 PostLogin() :プレイヤーが正常に接続され、検証された後、PostLogin()関数が呼び出されます。ここでは、プレイヤーがゲームに参加した後すぐに実行する必要のあるコードを実行できます。例えば、歓迎メッセージを送信したり、プレイヤーのゲームデータを初期化したりできます。 InitNewPlayer() :この関数はプレイヤーがサーバーに接続して初期化されたときに呼び出されます。この関数では、「プレイヤーの属性の初期化」「プレイヤーのチームの割り当て」「新しいプレイヤーに必要なゲーム情報の送信」など、多くのタスクを実行できます。 BeginPlay() :この関数はゲームの開始時に呼び出されます。ゲーム開始時に実行する必要があるコードをここで実行できます。 Logout() :プレイヤーがゲームから退出するときにこの関数が呼び出されます。ここでは、プレイヤーがゲームから退出する際に実行する必要のあるコード(例:プレイヤーのゲームデータの保存や、プレイヤーの退出メッセージの送信など)を実行できます。 これらの関数は最も一般的に使用され、ゲームの異なる段階で何を実行するかをサーバーサイドで処理するために使用されます。    これらの関数により、ゲームのロジックや要件に合わせて特定のコードを適切なタイミングで実行できます。 前述のように、同期を実現するには2つの条件を満たす必要があります。 「② 属性の変更コードはDedicated Server上で実行される」 という条件を満たすために、上記の関数の中で、特に InitNewPlayer() 関数を選択する必要があります。 なぜなら、この関数はプレイヤーの PlayState 初期化が行われるタイミングですから。 //.xxxGameMode.h virtual FString InitNewPlayer(APlayerController* NewPlayerController, const FUniqueNetIdRepl& UniqueId, const FString& Options, const FString& Portal) override; //.xxxGameMode.cpp FString xxxServerGameMode::InitNewPlayer(APlayerController* NewPlayerController, const FUniqueNetIdRepl& UniqueId, const FString& Options, const FString& Portal) { FString InitializedString = Super::InitNewPlayer(NewPlayerController, UniqueId, Options, Portal); const FString& nickName = UGameplayStatics::ParseOption(Options, "NickName"); if (NewPlayerController != nullptr) { APlayerState* PlayerState = NewPlayerController->PlayerState; if (PlayerState != nullptr) { AMetaPlayerState* ServerPlayerState = Cast<AMetaPlayerState>(PlayerState); if (ServerPlayerState) { ServerPlayerState->NickName = nickName; } } } return InitializedString; } 5.デモの確認 ここまでで、ユーザーネームの レプリケーション に関する実装が全部完了しました! Dedicated Serverをパッケージ化して試してみましょう。確認ポイントは以下となります: 各クライアントでユーザーがユーザーネームを入力し、ゲームが開始できること 各クライアントで対応するキャ ラク ターの名前が表示されること 上記の確認が取れましたら、いわゆるネットワーク上での状態同期が成功し、すべてのクライアントが一貫したゲーム世界を見ることができるようになりました! ※パッケージング手順については Amazon GameLift × Unreal Engines 5 でオンラインマルチプレイゲームを作る の記事を参照してください。 注意事項 属性の同期を実装する際には、以下の点を注意してください。 それは、キャ ラク ターモデルの変更をAPlayerStateクラスに実装しないということです。 筆者がコードを書く際、最初に APlayerState クラスに以下のようなコールバック関数を書いたことがありました。 void ATestPlayerState::OnRep_NickName() { APlayerController* PC = GetGameInstance()->GetFirstLocalPlayerController(); if (PC && PC->GetPawn()) { AMetaPlayerController* PlayerController = Cast<AMetaPlayerController>(PC); if (PlayerController) { Atest_DEServerCharacter* MyCharacter = Cast<Atest_DEServerCharacter>(PlayerController->GetPawn()); if (MyCharacter) { MyCharacter->playerNameTag->SetText(FText::FromString(NickName)); } } } } 実行結果として、もともとPlayer1を制御していたユーザーが、Player2がログインした後に制御しているキャ ラク ターの表示がPlayer2のユーザー名になってしまうという問題が発生しました。    これは、私が APlayerState の OnRep_NickName 関数でキャ ラク ターを取得し、名前ラベルを変更していたためです。 この関数は、NickNameフィールドがクライアントに複製されたときに呼び出されます。しかし一部の場合では、NickNameフィールドがクライアントに複製された時点では、クライアントが新しいキャ ラク ターの情報をまだ受信していない可能性があります。つまり、GetPawn()関数がnullを返すか、もしくはキャ ラク ターが存在していても既に存在する他のプレイヤーのキャ ラク ターになるかもしれません。 この問題を解決するための方法は、 APlayerState のOnRep関数内でキャ ラク ターを取得し、名前ラベルを変更しないことです。 代わりに、キャ ラク ターのOnRep_PlayerState関数内で、キャ ラク ター自身のPlayerStateを取得しキャ ラク ターの名前ラベルを変更する必要があります。先ほど実装したコードと同様に、キャ ラク ター自身のOnRep_PlayerState関数でこれを行ってください。 終わりに このユーザーネーム属性の レプリケーション デモを通じて、 Unreal Engine のネットワーク同期モデルについて一定の理解を得ることができました。 Unreal Engine は、さまざまなタイプのゲームや仮想現実アプリケーションをサポートする強力な ゲームエンジン です。3Dおよび メタバース の開発領域では、ネットワーク同期、 マルチプレイヤー ゲーム、物理シミュレーション、シーンの レンダリング など、さまざまな技術と応用を探求できます。学習と研究を続けることで、この領域においてより深い理解と高い技術力を身につけることができます。 現在ISIDは web3領域のグループ横断組織 を立ち上げ、Web3および メタバース 領域のR&Dを行っております(カテゴリー「3DCG」の記事は こちら )。 もし本領域にご興味のある方や、一緒にチャレンジしていきたい方は、ぜひお気軽にご連絡ください! 私たちと同じチームで働いてくれる仲間を、是非お待ちしております! ISID採用ページ(Web3/メタバース/AI) 参考文献 https://docs.unrealengine.com/5.2/en-US/networking-overview-for-unreal-engine/ 執筆: @chen.sun 、レビュー: @yamashita.yuki ( Shodo で執筆されました )
電通国際情報サービス 、オープン イノベーション ラボの 比嘉康雄 です。 今回は、 Expoの公式チュートリアル をやっていきたいと思います。 プロジェクトの作成 Download assets Install dependencies アプリの実行 コードの編集 テキストの文字色の変更 イメージの表示 ImageViewerコンポーネント Buttonコンポーネント PhotoButtonコンポーネント 画像の選択 まとめ 仲間募集 プロジェクトの作成 StickerSmash プロジェクトを作成しましょう。 npx create-expo-app StickerSmash && cd StickerSmash Download assets https://docs.expo.dev/static/images/tutorial/sticker-smash-assets.zip をダウンロードしてから解凍し、StickerSmash/assetsに格納します。既存のファイルは置き換えてください。 Install dependencies Webでも実行できるようにするために、必要なモジュールをインストールします。 npx expo install react-dom react-native-web @expo/webpack-config アプリの実行 プロジェクトの ディレクト リで、 npx expo start を実行します。 wを押すと、Webアプリを試すことができます。 次のように表示されました。 コードの編集 プロジェクトの ディレクト リで、 code . を実行して、 VS Code を立ち上げます。 背景を #25292e に変更しましょう。 const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#25292e", alignItems: "center", justifyContent: "center", }, }); 次のように表示されました。 テキストのデフォルトの文字色は黒なので、背景が黒っぽくなったことで、文字が見え辛くなってしまいました。 テキストの文字色の変更 Textタグのstyle属性で、文字の色を白に指定しましょう。 <Text style={{ color: "#fff" }}> Open up App.js to start working on your app! </Text> 次のように表示されました。 イメージの表示 イメージを表示しましょう。 Image コンポーネント をインポートします。 import { StyleSheet, View, Image } from 'react-native'; 次は、イメージパスの設定です。 const PlaceholderImage = require('./assets/images/background-image.png'); Imageタグを使う前に、 スタイルシート の設定をしましょう。 const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#25292e", alignItems: "center", }, imageContainer: { flex: 1, paddingTop: 58, }, image: { width: 320, height: 440, borderRadius: 18, }, }); 準備ができたので、Image コンポーネント を使ってみましょう。 <View style={styles.imageContainer}> <Image source={PlaceholderImage} style={styles.image} /> </View> 下記のように表示されました。 ImageViewer コンポーネント 先ほど作ったイメージ表示機能を コンポーネント 化しましょう。 StickSmash ディレクト リの中にcomponents ディレクト リを作成します。 .../StickerSmash$ mkdir components VS Code で ImageViewer.js を作成します。 code components/ImageViewer.js 下記のコードを ImageViewer.js に書き込みます。 import { StyleSheet, Image } from 'react-native'; export default function ImageViewer({ placeholderImageSource }) { return ( <Image source={placeholderImageSource} style={styles.image} /> ); } const styles = StyleSheet.create({ image: { width: 320, height: 440, borderRadius: 18, }, }); App.js から ImageViewer.js を呼び出します。 import ImageViewer from './components/ImageViewer'; const PlaceholderImage = require('./assets/images/background-image.png'); export default function App() { return ( <View style={styles.container}> <View style={styles.imageContainer}> <ImageViewer placeholderImageSource={PlaceholderImage} /> </View> <StatusBar style="auto" /> </View> ); } 見た目はもちろん以前と一緒です。 Button コンポーネント これからボタンを2つ作りますが、その前に、Button コンポーネント を作ってそれを利用することにしましょう。 VS Code で Button.js を作成します。 code components/Button.js 下記のコードを Button.js に書き込みます。 import { StyleSheet, View, Pressable, Text } from 'react-native'; export default function Button({ label }) { return ( <View style={styles.buttonContainer}> <Pressable style={styles.button} onPress={() => alert('You pressed a button.')}> <Text style={styles.buttonLabel}>{label}</Text> </Pressable> </View> ); } const styles = StyleSheet.create({ buttonContainer: { width: 320, height: 68, marginHorizontal: 20, alignItems: 'center', justifyContent: 'center', padding: 3, }, button: { borderRadius: 10, width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center', flexDirection: 'row', }, buttonLabel: { color: '#fff', fontSize: 16, }, }); App.js から Button.js を呼び出します。 import Button from './components/Button'; <View style={styles.footerContainer}> <Button label="Choose a photo" /> <Button label="Use this photo" /> </View> const styles = StyleSheet.create({ // Styles that are unchanged from previous step are hidden for brevity. footerContainer: { flex: 1 / 3, alignItems: 'center', }, }); 下記のように表示されました。 PhotoButton コンポーネント 写真アイコンが表示されるボタンを作りましょう。 @expo/vector-icon をインストールします。 npx expo install @expo/vector-icons VS Code で PhotoButton.js を作成します。 code components/PhotoButton.js 下記のコードを PhotoButton.js に書き込みます。 import { StyleSheet, View, Pressable, Text } from "react-native"; import FontAwesome from "@expo/vector-icons/FontAwesome"; export default function PhotoButton({ label }) { return ( <View style={styles.buttonContainer}> <Pressable style={styles.button} onPress={() => alert("You pressed a button.")} > <FontAwesome name="picture-o" size={18} color="#25292e" style={styles.buttonIcon} /> <Text style={[styles.buttonLabel, {}]}>{label}</Text> </Pressable> </View> ); } const styles = StyleSheet.create({ buttonContainer: { width: 320, height: 68, marginHorizontal: 20, alignItems: "center", justifyContent: "center", padding: 3, borderWidth: 4, borderColor: "#ffd33d", borderRadius: 18, }, button: { borderRadius: 10, width: "100%", height: "100%", alignItems: "center", justifyContent: "center", flexDirection: "row", backgroundColor: "#fff", }, buttonIcon: { paddingRight: 8, }, buttonLabel: { color: "#25292e", fontSize: 16, }, }); 下記のように表示されました。 画像の選択 画像を選択する機能を実装しましょう。 expo-image-picker をインストールします。 npx expo install expo-image-picker App.js で pickImageAsync ファンクションを実装しましょう。選択した画像は、 result.assets[0].uri に入っています。 import * as ImagePicker from 'expo-image-picker'; const pickImageAsync = async () => { const result = await ImagePicker.launchImageLibraryAsync({ allowsEditing: true, quality: 1, }); if (!result.canceled) { alert(result.assets[0].uri); } else { alert('You did not select any image.'); } }; PhotoButton でボタンを押したときの動作が定義できるように、 onPress 属性を追加しましょう。 export default function PhotoButton({ label, onPress }) { return ( <View style={styles.buttonContainer}> <Pressable style={styles.button} onPress={onPress} > <FontAwesome name="picture-o" size={18} color="#25292e" style={styles.buttonIcon} /> <Text style={styles.buttonLabel>{label}</Text> </Pressable> </View> ); } App.js で PhotoButton が押されたときに、 pickImageAsync ファンクションを呼び出します。 <PhotoButton label="Choose a photo" onPress={pickImageAsync} /> ImageViewer で選択した画像を表示できるように、 selectedImage 属性を追加します。 export default function ImageViewer( { placeholderImageSource, selectedImage }) { const imageSource = selectedImage ? { uri: selectedImage } : placeholderImageSource; return <Image source={imageSource} style={styles.image} />; } App.js で、写真が選択されたら、 ImageViewer に表示します。 import { useState } from "react"; const PlaceholderImage = require("./assets/images/background-image.png"); export default function App() { const [selectedImage, setSelectedImage] = useState(null); const pickImageAsync = async () => { const result = await ImagePicker.launchImageLibraryAsync({ allowsEditing: true, quality: 1, }); if (!result.canceled) { setSelectedImage(result.assets[0].uri); } else { alert("You did not select any image."); } }; return ( <View style={styles.container}> <View style={styles.imageContainer}> <ImageViewer placeholderImageSource={PlaceholderImage} selectedImage={selectedImage} /> </View> <View style={styles.footerContainer}> <PhotoButton label="Choose a photo" onPress={pickImageAsync} /> <Button label="Use this photo" /> </View> <StatusBar style="auto" /> </View> ); } 下記のように表示されました。 切りが良いので、前編はここまでとします。 まとめ Expoの公式チュートリアル は、難易度もちょうどいい感じで、実践的な力が身につくなと感じました。ただ、 JavaScript じゃなくTypeScriptにしてほしかったところです。 仲間募集 私たちは同じグループで共に働いていただける仲間を募集しています。 現在、以下のような職種を募集しています。 ソリューションアーキテクト AIエンジニア 執筆: @higa ( Shodo で執筆されました )
電通国際情報サービス 、オープン イノベーション ラボの 比嘉康雄 です。 今回は、 Expoの公式チュートリアル をやっていきたいと思います。 プロジェクトの作成 Download assets Install dependencies アプリの実行 コードの編集 テキストの文字色の変更 イメージの表示 ImageViewerコンポーネント Buttonコンポーネント PhotoButtonコンポーネント 画像の選択 まとめ 仲間募集 プロジェクトの作成 StickerSmash プロジェクトを作成しましょう。 npx create-expo-app StickerSmash && cd StickerSmash Download assets https://docs.expo.dev/static/images/tutorial/sticker-smash-assets.zip をダウンロードしてから解凍し、StickerSmash/assetsに格納します。既存のファイルは置き換えてください。 Install dependencies Webでも実行できるようにするために、必要なモジュールをインストールします。 npx expo install react-dom react-native-web @expo/webpack-config アプリの実行 プロジェクトの ディレクト リで、 npx expo start を実行します。 wを押すと、Webアプリを試すことができます。 次のように表示されました。 コードの編集 プロジェクトの ディレクト リで、 code . を実行して、 VS Code を立ち上げます。 背景を #25292e に変更しましょう。 const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#25292e", alignItems: "center", justifyContent: "center", }, }); 次のように表示されました。 テキストのデフォルトの文字色は黒なので、背景が黒っぽくなったことで、文字が見え辛くなってしまいました。 テキストの文字色の変更 Textタグのstyle属性で、文字の色を白に指定しましょう。 <Text style={{ color: "#fff" }}> Open up App.js to start working on your app! </Text> 次のように表示されました。 イメージの表示 イメージを表示しましょう。 Image コンポーネント をインポートします。 import { StyleSheet, View, Image } from 'react-native'; 次は、イメージパスの設定です。 const PlaceholderImage = require('./assets/images/background-image.png'); Imageタグを使う前に、 スタイルシート の設定をしましょう。 const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#25292e", alignItems: "center", }, imageContainer: { flex: 1, paddingTop: 58, }, image: { width: 320, height: 440, borderRadius: 18, }, }); 準備ができたので、Image コンポーネント を使ってみましょう。 <View style={styles.imageContainer}> <Image source={PlaceholderImage} style={styles.image} /> </View> 下記のように表示されました。 ImageViewer コンポーネント 先ほど作ったイメージ表示機能を コンポーネント 化しましょう。 StickSmash ディレクト リの中にcomponents ディレクト リを作成します。 .../StickerSmash$ mkdir components VS Code で ImageViewer.js を作成します。 code components/ImageViewer.js 下記のコードを ImageViewer.js に書き込みます。 import { StyleSheet, Image } from 'react-native'; export default function ImageViewer({ placeholderImageSource }) { return ( <Image source={placeholderImageSource} style={styles.image} /> ); } const styles = StyleSheet.create({ image: { width: 320, height: 440, borderRadius: 18, }, }); App.js から ImageViewer.js を呼び出します。 import ImageViewer from './components/ImageViewer'; const PlaceholderImage = require('./assets/images/background-image.png'); export default function App() { return ( <View style={styles.container}> <View style={styles.imageContainer}> <ImageViewer placeholderImageSource={PlaceholderImage} /> </View> <StatusBar style="auto" /> </View> ); } 見た目はもちろん以前と一緒です。 Button コンポーネント これからボタンを2つ作りますが、その前に、Button コンポーネント を作ってそれを利用することにしましょう。 VS Code で Button.js を作成します。 code components/Button.js 下記のコードを Button.js に書き込みます。 import { StyleSheet, View, Pressable, Text } from 'react-native'; export default function Button({ label }) { return ( <View style={styles.buttonContainer}> <Pressable style={styles.button} onPress={() => alert('You pressed a button.')}> <Text style={styles.buttonLabel}>{label}</Text> </Pressable> </View> ); } const styles = StyleSheet.create({ buttonContainer: { width: 320, height: 68, marginHorizontal: 20, alignItems: 'center', justifyContent: 'center', padding: 3, }, button: { borderRadius: 10, width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center', flexDirection: 'row', }, buttonLabel: { color: '#fff', fontSize: 16, }, }); App.js から Button.js を呼び出します。 import Button from './components/Button'; <View style={styles.footerContainer}> <Button label="Choose a photo" /> <Button label="Use this photo" /> </View> const styles = StyleSheet.create({ // Styles that are unchanged from previous step are hidden for brevity. footerContainer: { flex: 1 / 3, alignItems: 'center', }, }); 下記のように表示されました。 PhotoButton コンポーネント 写真アイコンが表示されるボタンを作りましょう。 @expo/vector-icon をインストールします。 npx expo install @expo/vector-icons VS Code で PhotoButton.js を作成します。 code components/PhotoButton.js 下記のコードを PhotoButton.js に書き込みます。 import { StyleSheet, View, Pressable, Text } from "react-native"; import FontAwesome from "@expo/vector-icons/FontAwesome"; export default function PhotoButton({ label }) { return ( <View style={styles.buttonContainer}> <Pressable style={styles.button} onPress={() => alert("You pressed a button.")} > <FontAwesome name="picture-o" size={18} color="#25292e" style={styles.buttonIcon} /> <Text style={[styles.buttonLabel, {}]}>{label}</Text> </Pressable> </View> ); } const styles = StyleSheet.create({ buttonContainer: { width: 320, height: 68, marginHorizontal: 20, alignItems: "center", justifyContent: "center", padding: 3, borderWidth: 4, borderColor: "#ffd33d", borderRadius: 18, }, button: { borderRadius: 10, width: "100%", height: "100%", alignItems: "center", justifyContent: "center", flexDirection: "row", backgroundColor: "#fff", }, buttonIcon: { paddingRight: 8, }, buttonLabel: { color: "#25292e", fontSize: 16, }, }); 下記のように表示されました。 画像の選択 画像を選択する機能を実装しましょう。 expo-image-picker をインストールします。 npx expo install expo-image-picker App.js で pickImageAsync ファンクションを実装しましょう。選択した画像は、 result.assets[0].uri に入っています。 import * as ImagePicker from 'expo-image-picker'; const pickImageAsync = async () => { const result = await ImagePicker.launchImageLibraryAsync({ allowsEditing: true, quality: 1, }); if (!result.canceled) { alert(result.assets[0].uri); } else { alert('You did not select any image.'); } }; PhotoButton でボタンを押したときの動作が定義できるように、 onPress 属性を追加しましょう。 export default function PhotoButton({ label, onPress }) { return ( <View style={styles.buttonContainer}> <Pressable style={styles.button} onPress={onPress} > <FontAwesome name="picture-o" size={18} color="#25292e" style={styles.buttonIcon} /> <Text style={styles.buttonLabel>{label}</Text> </Pressable> </View> ); } App.js で PhotoButton が押されたときに、 pickImageAsync ファンクションを呼び出します。 <PhotoButton label="Choose a photo" onPress={pickImageAsync} /> ImageViewer で選択した画像を表示できるように、 selectedImage 属性を追加します。 export default function ImageViewer( { placeholderImageSource, selectedImage }) { const imageSource = selectedImage ? { uri: selectedImage } : placeholderImageSource; return <Image source={imageSource} style={styles.image} />; } App.js で、写真が選択されたら、 ImageViewer に表示します。 import { useState } from "react"; const PlaceholderImage = require("./assets/images/background-image.png"); export default function App() { const [selectedImage, setSelectedImage] = useState(null); const pickImageAsync = async () => { const result = await ImagePicker.launchImageLibraryAsync({ allowsEditing: true, quality: 1, }); if (!result.canceled) { setSelectedImage(result.assets[0].uri); } else { alert("You did not select any image."); } }; return ( <View style={styles.container}> <View style={styles.imageContainer}> <ImageViewer placeholderImageSource={PlaceholderImage} selectedImage={selectedImage} /> </View> <View style={styles.footerContainer}> <PhotoButton label="Choose a photo" onPress={pickImageAsync} /> <Button label="Use this photo" /> </View> <StatusBar style="auto" /> </View> ); } 下記のように表示されました。 切りが良いので、前編はここまでとします。 まとめ Expoの公式チュートリアル は、難易度もちょうどいい感じで、実践的な力が身につくなと感じました。ただ、 JavaScript じゃなくTypeScriptにしてほしかったところです。 仲間募集 私たちは同じグループで共に働いていただける仲間を募集しています。 現在、以下のような職種を募集しています。 ソリューションアーキテクト AIエンジニア 執筆: @higa ( Shodo で執筆されました )