Agones ゲームサーバーとWebRTC接続を確立する

Kubernetes上でゲームサーバーを管理する拡張: Agonesはユーザーがクライアントとなり、ゲームサーバーに直接接続を確立する1、いわゆるクライアント・サーバーモデルである。通信プロトコルはTCP/UDPの上で動くものであれば自由に設定できる。

そして、リアルタイム通信方式の中でも有名なものの一つとして WebRTC がある。主要なWebブラウザに標準実装されている仕組みで、リアルタイムにメディア(映像・音声)を配信できる。 今回はこのWebRTCをAgones上のゲームサーバーとの通信で使うケースを考える。

WebRTCの代表的な接続の確立方法をインターネット等で調べると、Webブラウザ同士がP2P接続、つまりNATの内側にいる両者がSTUN/TURNサーバーを使って通信を確立するという解説が多い。それはAgonesのようなクライアント・サーバーモデルとは異なるため、「もしかしてAgonesではWebRTC接続ができないのだろうか?」と疑問に思うかもしれない。

結論を先にいうと、AgonesでもWebRTC接続確立はできる。 ただし、Agonesの仕組みに上手く乗りクライアント・サーバーモデルとして接続するにはサーバー側に特別な設定が必要となるため、 本記事ではAgones GameServerとのWebRTC接続のやり方を解説する。

直接接続にSTUN/TURNは必要か

一般的に通信を開始するには相手のアドレスを知る必要があるが、WebRTCではお互いがNATの内部にいることを考慮して ICE そして STUN/TURNという技術でお互いのアドレスを交換する。 直接接続ができない場合に備えて中継という手段も用意されている。

しかし、Agonesは前述の通りゲームサーバーのアドレスがインターネットに公開され、ユーザーは事前にアドレスが分かる。 よって外部のSTUN/TURNサーバーを使ってNATを超えて通信を確立する処理はスキップできるはずだ。

WebRTCで用いられる接続確立の仕組み ICE (Interactive Connectivity Establishment) にはこのような用途で使える Lite implementation がある。

Consequently, a lite implementation is only appropriate for devices that will always be connected to the public Internet and have a public IP address at which it can receive packets from any correspondent. ICE will not function when a lite implementation is placed behind a NAT.

https://datatracker.ietf.org/doc/html/rfc8445#appendix-A

外部からアクセス可能なIPアドレスがある場合はそれを直接接続可能な候補として伝えてしまえばOK、というわけだ。 GoのWebRTCライブラリ pion/webrtc を使う場合、次のように設定できる。

settingEngine := webrtc.SettingEngine{}
settingEngine.SetLite(true)  // Lite implementationを有効化

api := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{
    // STUN/TURNサーバーの設定は不要
})

この設定を行うとAgonesゲームサーバー側は Lite implementationになり 外部のSTUN/TURNサーバーを使ってNATの内側から相手と通信をするための準備が不要になる2

ポート番号の対応付け

Agonesは外部に向けてIPアドレスとポートを公開する。ただしポート番号の割り当てが少し特殊である。 ゲームサーバーの実装がUDP port: 7000 で通信を行う場合、GameServerのyamlには次のように書く。

spec:
  ports:
    - name: webrtc
      portPolicy: Dynamic  # 外から見たポートはランダムに割り当て
      containerPort: 7000  # コンテナ内では常に7000
      protocol: UDP

portPolicy のコメントの通りこの場合外から見たポート番号は7000以外になる。 同じホストにコンテナを複数設置できるようにAgonesがランダムに割り当てるからだ。 内部ではUDP: 7000 で待ち受けているが、外から見たポートは UDP: 7234 といった風に対応付けされる。

このように内外でポート番号が異なる場合を、WebRTCの接続でどう対処すべきか? 上と同様にGoの pion/webrtc を例に解説する。

pion/webrtc(が依存する pion/ice )には UDPMux という仕組みがあり、WebRTCの通信を1つのUDPソケット、つまり1つのポート番号に固定できる。 また、UDPMux には候補に出すアドレスを返すメソッド GetListenAddresses があるので、 そこをラップして独自に実装することで実際のソケットとは異なるIPアドレスとポート番号を候補アドレスとして伝えられる。

type AgonesUDPMux struct {
    ice.UDPMux
    externalAddr *net.UDPAddr
}

func NewAgonesUDPMux(inner ice.UDPMux, externalIP string, externalPort int) *AgonesUDPMux {
    return &AgonesUDPMux{
        UDPMux: inner,
        externalAddr: &net.UDPAddr{
            IP:   net.ParseIP(externalIP),
            Port: externalPort,
        },
    }
}

func (m *AgonesUDPMux) GetListenAddresses() []net.Addr {
    return []net.Addr{m.externalAddr}
}

次のようにAgonesから取得した外向きのIPアドレスとポート番号を設定することでWebRTCの通信相手(ユーザー)に外向けのアドレスが伝わり通信が成立する。 内向きのソケットは 7000番で待ち受けるだけでよい。

// Agonesから外部アドレスを取得
gs, err := sdk.GameServer()
externalIP := gs.Status.Address
externalPort := int(gs.Status.Ports[0].Port)

// コンテナ内の固定ポートでlisten
udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 7000})

// 標準のUDPMuxを作成し、AgonesUDPMuxでラップ
innerMux := webrtc.NewICEUDPMux(nil, udpConn)
agonesUDPMux := NewAgonesUDPMux(innerMux, externalIP, externalPort)

// SettingEngineに設定
settingEngine := webrtc.SettingEngine{}
settingEngine.SetLite(true)
settingEngine.SetICEUDPMux(agonesUDPMux)

// APIとPeerConnectionを作成
api := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
peerConnection, _ := api.NewPeerConnection(webrtc.Configuration{
    // STUN/TURNサーバーの設定は不要
})

以上の設定で、WebRTCであっても固定の公開アドレス、ポートに対して直接接続を確立できるようになった。 このようなWebRTCの使い方の事例はインターネットにもあまりない気がするので、何かの参考になれば幸いである。

ソースコード実例

ここまでの内容を実装し、Agones上に実際にデプロイ可能にしたソースコードを用意した。 筆者の手元でAkamai CloudのKubernetes上にデプロイしてみたところ、Agonesの公開アドレスを使ってDataChannelによる通信ができることが確認できた。

https://github.com/castaneai/webrtc-on-agones


  1. Agones はなぜ、どのようにPodへの直接接続を実現しているか - castaneaiのブログ で解説している
  2. 外部STUNサーバーの指定は不要になるが、STUNプロトコルが完全になくなるわけではない。サーバー側がLite implementationであっても、クライアントからのSTUN binding requestに応答する必要はあるからだ。