Fly.io で UDP ゲームサーバー(Godot)をデプロイ・スケーリングする方法
時代はコンテナなので、Godotで作成したゲームサーバーをDockerizeしてホスティングできるサービスが無いかと考えたところ、仕事でも使ってる Fly.io が良さげだった。
仕事で使ってるものの、あんまり詳しくないのでこれを機に色々調べて実践してみる。
多分、ここで実践していることはゲームサーバーに限らない UDP サービスで同様に使えると思う。
この記事で使用している Godot のバージョンは v4.2.1.stable です
Fly.io での UDP アプリケーションのデプロイ
Dockerfile を用意して fly launch
する。
FROM barichello/godot-ci as build
WORKDIR /app
COPY . /app
RUN godot --headless --export-debug "Linux/X11" /app/server.pck
FROM ubuntu:latest as app
WORKDIR /app
RUN apt-get update && apt-get install -y \
curl \
unzip
RUN curl -LO https://github.com/godotengine/godot-builds/releases/download/4.2.1-stable/Godot_v4.2.1-stable_linux.x86_64.zip \
&& unzip Godot_v4.2.1-stable_linux.x86_64.zip \
&& rm Godot_v4.2.1-stable_linux.x86_64.zip
RUN apt-get install -y fontconfig
COPY /app/server.pck /app/server.pck
# Godot can't listen the hostname, so we need to fix it to IP address
RUN export IP_ADDR=$(cat /etc/hosts | grep fly-global-services | awk '{print $1}')
ENTRYPOINT ["./Godot_v4.2.1-stable_linux.x86_64", "--headless", "--main-pack", "server.pck", "--server"]
app = "godot-game-server-pool"
primary_region = "nrt"
[build]
[env]
PORT = "5000"
[[services]]
protocol = "udp"
internal_port = 5000
[[services.ports]]
port = 5000
[[vm]]
cpu_kind = "shared"
cpus = 1
memory_mb = 256
Fly で UDPトラフィックを受信するには、外部/内部で同じポートでlistenする必要がある
flyctl launch
flyctl deploy
flyctl ips allocate-v4
Fly.io のアプリを作成するとデフォルトで IPv6 が割り当てられるが、現在(2024年1月時点)ではFly.ioでの UDP over IPv6 がサポートされていない。
UDP アプリケーションは専用IPv4アドレスを割り当てる必要がある。(有料 2$/mo)
更に、UDPサーバーの場合は 0.0.0.0
とか *
とかではなく、fly-global-services
ホストをバインドする必要がある。
UDPサーバーマシンを起動すると、 /etc/hosts
に fly-global-services
が追加される。
ただし、Godotでゲームサーバーを立てる際はホスト名でリッスンできないので、IPアドレスを取り出して環境変数で渡すようにする。
RUN export IP_ADDR=$(cat /etc/hosts | grep fly-global-services | awk '{print $1}')
サーバー/クライアント GDScript
とりあえず、
ゲームを起動するときのコマンド引数 (--server
/--client
) でモードを切り替える
extends Node2D
var IP_ADDR = "*"
var PORT = 5000
var SERVER_DOMAIN = "localhost"
func _ready() -> void:
# load env
if OS.get_environment("IP_ADDR"):
IP_ADDR = OS.get_environment("IP_ADDR")
if OS.get_environment("PORT"):
PORT = int(OS.get_environment("PORT"))
if OS.get_environment("SERVER_DOMAIN"):
SERVER_DOMAIN = OS.get_environment("SERVER_DOMAIN")
var args = OS.get_cmdline_args()
if "--server" in args:
start_server()
elif "--client" in args:
start_client()
else:
print("pass --server or --client to args")
get_tree().quit()
func start_server():
var server = ENetMultiplayerPeer.new()
server.set_bind_ip(IP_ADDR)
server.create_server(PORT, 4)
multiplayer.multiplayer_peer = server
multiplayer.peer_connected.connect(func(id):
print("connected by: ", id)
)
multiplayer.peer_disconnected.connect(func(id):
print("disconnected by: ", id)
)
func start_client():
var client = ENetMultiplayerPeer.new()
client.create_client(SERVER_DOMAIN, PORT)
print("connecting to server...")
multiplayer.multiplayer_peer = client
multiplayer.connected_to_server.connect(func():
print("connected to server")
)
multiplayer.server_disconnected.connect(func():
print("disconnected from server")
)
デプロイしたサーバーへの接続を試すには以下のコマンド
(Godot の実行ファイルに alias を貼っておくのを前提とする)
export SERVER_DOMAIN="{app_name}.fly.dev"
godot --headless -d main.tscn --client
UDPサーバーをスケールアウトする
実際にゲームサービスを運用するとなると、プレイヤーの数に応じてゲームサーバーを増やす必要がある。
ゲームサーバーマネージャー的な別の Fly App を立ち上げるのがベターだが、ここでは簡単にシェルスクリプトでやってみる。
NGな方法
app のコンフィグによる flyctl scale
コマンドだと、同じポート設定のままマシンを増やすので良くない。
試したところ、 5000 ポートをリッスンしているので {app_name}.fly.dev:5000
にUDPを飛ばすと同じポートをlistenするマシンにラウンドロビン方式で順番にUDPリクエストが振り分けられる。
Webサーバーのようなステートレスなものなら問題ないが、リアルタイムゲームのサーバーのようなステートフルなものは接続を確立した特定の同じマシンに毎回リクエストを送りたい。
# これはダメ
flyctl scale count 3
OKな方法: Fly Machine API
fly は REST API 経由でマシンを作成・開始・停止・削除できる。
マニュアル作成では作成するマシンごとに詳細を設定できる。
例えば、マシンごとに個別にポートマッピングなどが設定できるので、特定のマシンにアクセスすることができるようになる。
例. 5001番ポートをリッスンするマシンの作成
OFFSET=1
PORT_START=5000
PORT=$(($PORT_START + $OFFSET))
CONFIG='{
"config": {
"env": {
"PORT": "'$PORT'"
},
"guest": {
"cpu_kind": "shared",
"cpus": 1,
"memory_mb": 256
},
"services": [
{
"protocol": "udp",
"internal_port": '$PORT',
"ports": [
{
"port": '$PORT'
}
]
}
],
"image": "registry.fly.io/{app_name}:latest"
}
}'
FLY_TOKEN="your fly token"
curl -X POST \
-d "$CONFIG" \
-H "Authorization: Bearer $FLY_TOKEN" \
"https://api.machines.dev/v1/apps/{app_name}/machines"
これで作成すると、 {app_name}.fly.dev:5001
のドメインからアクセスできるUDPゲームサーバーが立ち上がる。
更に増やしたいときは 5002, 5003, 5004...
と既に立てたサーバーと被らないポートを設定していく。
flyctl deploy するときの docker image ラベル
そのまま flyctl deploy
すると、Deployment ID を含んだラベルになる。
例. registry.fly.io/{app_name}:deployment-xxxx
この Deployment ID を調べるのが面倒なので、毎回 :latest
ラベルを付けてデプロイしておく
flyctl deploy --image-label latest
Fly Machine の効率的な起動
ドキュメントによると、Fly Machine の start/stop のほうが create/destroy と比べてかなり速い。
利用されるサーバー数を事前に予測できるなら、足りなくなるまえに事前に作成しておいて start,または使い終わった Machine は破棄せず stop して再利用するとアプリケーションのスケーリングが高速化するかも。
番外: UDP ではなく WebSocket でリアルタイムサーバーを立てる時
Fly App へのリクエストは基本的にすべて Fly Proxy を経由している。
HTTPリクエストだと fly-replay
ヘッダーを使って特定のマシンにリクエストをルーティングができる。
つまり、WebSocketでも最初のコネクション時のリクエストが HTTP なので、これを利用して特定のマシンにつなぐことができそう...?
(試してないのでわからないが...)
そのた役立ちそうなページ
- https://community.fly.io/t/does-fly-replay-work-with-websockets/10963
- https://fly.io/blog/replicache-machines-demo/
UDPのほうが通信速度は速いが、WebSocketはHTTP関連のサポートの恩恵を受けられるしブラウザでも動作するので、トレードオフ。
Fly.io でホストすることを考えても、UDPサービスはWebSocketと比べてポートの管理が若干複雑な気がするので、それを考えなくていいというのもある。
ちなみに Godot では WebScoket peer を用いたマルチプレイヤーも可能。
感想・まとめ
Fly.io での Docker コンテナを使ったサービスの設定は簡単でやりやすいと思う。
(AWS ECS とか Kubernetes と比較して)
デプロイするだけならコマンド一発。マシンスペックのカスタマイズもConfigファイルベースでできる。
REST API 経由での操作も可能。
UDPサービスもできるし、ルーティングやマシンのスケーリングなど細かいところもいじれるのでおすすめ。
参考 : UDP echo デモ
同じポートで複数のUDPサーバーを立てるとラウンドロビン式で負荷分散されているのを確かめる時、下のサンプルを使用した
環境変数で FLY_MACHINE_ID
がわかるので、UDP echo のときにそれを付けて返す
machine_id := os.Getenv("FLY_MACHINE_ID")
bytes := []byte(fmt.Sprintf("%s: %s", machine_id, string(packet[:n])))
c.WriteTo(bytes, addr)
netcat などのクライアントで接続すると簡単にデバッグできる。
Discussion