Kubernetes で動くクラウドゲーミングにおけるCPU割当の工夫

Kubernetes上でゲームサーバーをホストする Agones を用いた環境において、CPU負荷にばらつきがあるゲーム群を動かしたいという事例があった。そこで、それぞれのゲームに対する適切なCPU割り当てを決定するために独自の工夫を行ったので、その実装について紹介する。

KubernetesにおけるCPU割り当て

Agonesのゲームサーバー群の定義(Fleet)ではGameServerの定義ができるが、 GameServer の中身はPodであり、Pod内のコンテナ毎にリソース使用量(CPU・メモリ)の要求と制限を設定できる。 たとえば次のFleetの例ではゲームサーバーのコンテナ1つにつき CPU: 1コアの要求と制限を設定している。

apiVersion: "agones.dev/v1"
kind: Fleet

# ... (snip) ...

          containers:
          - name: simple-game-server
            image: gcr.io/agones-images/simple-game-server:0.14
            resources:
              requests:
                cpu: "1"
              limits:
                cpu: "1"

このリソースに関する値は一度Podが作られると動的に変更することはできない1。 よって、この設定が実際のゲームに対して不適切だと次のような問題が起きる。

  • 負荷の低いゲームは、1コアを全然使い切れずリソースに無駄が生じる
  • 負荷の高いゲームは、1コアでは性能が足りず動作に悪影響が出る

クラウドゲーミング と CPU使用率

クラウドゲーミングは多種のゲームを提供していることが多い。 CPU負荷が高いゲームもあれば低いゲームもあるが、実際どれくらいの負荷がかかるのかは起動してみないとわからない。 さらに、クラウドゲーミングの性質上 ゲーム本体の負荷だけでなく、動画のエンコード処理の負荷も加わる。

したがって、「このゲームをクラウドゲーミングで快適に遊ぶにはCPUはnコア分必要だろう」という予測は難しい。クラウド上で起動して動画の配信込みで体験してみないとリソース使用量はわからないのである。

つまり、Agones GameServer の CPU Requests に何を設定すればいいか分からない という問題に突き当たる。 最初は「とりあえず、2コアぐらい割り当てておけば大抵のゲームは動くかな?」など雑に推測して cpu: "2000m" のように設定したが、推測のままではよくない。「推測するな、計測せよ」という有名な言葉にもある通り計測すべきである。

CPU使用率を上手く可視化する

Kubernetes では kubectl top pod コマンドでPodごとのCPU/メモリ使用率を見ることができる。 しかし、このPodごとのCPU/メモリ使用率だけでは情報としては弱い。 なぜなら、次の疑問に答えられないからだ。

  • そのPodでは今何のゲームが起動しているのか?
  • そのPodの中で何の処理にどれくらいのリソースが使用されているのか?
    • ゲーム本体、エンコード処理、…それぞれの割合は?
  • それぞれの使用率は YAML の CPU Requests に対してどれくらいの割合か?

まずひとつめの疑問「今なんのゲームが起動していのか?」に答えるには、現在起動しているゲームの情報が必要になる。

以前の overlayfs の記事 にも書いたが、多くのゲームタイトルを持つクラウドゲーミングでは何のゲームを遊ぶかは動的に決まる。 もう少し詳細に言うと Agones GameServer が allocate された後に API 経由でゲームの情報が降ってくる。

よって、ゲームの情報まで含めた指標を得るにはゲームサーバー内に独自の実装が必要になる。 独自の指標を作る方法として今回は有名な Prometheus を採用した。 公式ドキュメントの Instrumenting a Go application | Prometheus にもある通り、promauto というライブラリを使うとGoから簡単にカスタム値を prometheus 向けに公開できる。

今回は次のように欲しい情報をラベルに置いた prometheus metrics を定義した。

const (
    labelGameID        = "game_id"
    labelSessionID     = "session_id"
    labelProcessName   = "process_name"
    labelProcessPID    = "process_pid"
    labelPodCPURequest = "pod_cpu_request"
)

var (
    labels    = []string{labelGameID, labelSessionID, labelProcessName, labelProcessPID, labelPodCPURequest}
    cpuUsages = promauto.NewGaugeVec(prometheus.GaugeOpts{
        Name:      "process_cpu_usage_percent",
        Help:      "CPU usage percent per second used by the process. (min: 0, max: numCPU*100)",
    }, labels)
    cpuUtilizationByRequest = promauto.NewGaugeVec(prometheus.GaugeOpts{
        Name:      "process_cpu_utilization_percent_by_request",
        Help:      "CPU utilization per second corresponding to Pod's CPU requests used by the process. (min: 0, max: 100)",
    }, labels)
)

そして2つ目の問い「何の処理にどれくらいのリソースが使用されているのか?」を把握するためにはPod単位ではなくプロセス単位での使用率を取得すればよい。このために shirou/gopsutil: psutil for golang というライブラリを利用した。

次のように、起動中のプロセスを列挙してそれぞれのプロセス名とCPU使用率を取得している。

type processInfo struct {
    process     *process.Process
    processName string
}

func getProcesses(ctx context.Context) ([]*processInfo, error) {
    pids, err := process.PidsWithContext(ctx)
    if err != nil {
        return nil, fmt.Errorf("failed to enumerate pids: %w", err)
    }
    var pis []*processInfo
    for _, pid := range pids {
        p, err := process.NewProcessWithContext(ctx, pid)
        if errors.Is(err, process.ErrorProcessNotRunning) {
            // プロセス一覧取得→NewProcessまでのわずかな隙間にプロセスが終了した場合などはエラーではないのでスキップ
            continue
        }
        if err != nil {
            return nil, fmt.Errorf("failed to new process (pid: %d): %w", pid, err)
        }
        name, err := p.NameWithContext(ctx)
        if err != nil {
            return nil, fmt.Errorf("failed to get process name (pid: %d): %w", pid, err)
        }
        pis = append(pis, &processInfo{
            process:     p,
            processName: name,
        })
    }
    return pis, nil
}

type processMetrics struct {
    CPUUsagePercent float64
}

func fetchProcessMetrics(ctx context.Context, pi *processInfo) (*processMetrics, error) {
    percent, err := pi.process.PercentWithContext(ctx, 0)
    if err != nil {
        return nil, fmt.Errorf("failed to get CPU percentage (pid: %d, name: %s): %w", pi.process.Pid, pi.processName, err)
    }
    return &processMetrics{
        CPUUsagePercent: percent,
    }, nil
}

func isTargetProcess(pi *processInfo) bool {
    // PID:1 (pause container) は無視
    if pi.process.Pid == 1 {
        return false
    }
    return true
}

実はこのmetricsを収集するプロセスと、ゲーム本体、エンコード処理(GStreamer)はそれぞれ別のコンテナなのだが、Kubernetes の shareProcessNamespace を利用することで他コンテナのプロセスの取得を実現している。

Pod内のコンテナ構成を図にすると次のようになる。

ここでひとつ注意すべきは、gopsutil の Process.PercentWithContext で取得したCPU使用率は ノード全体のCPUに対する使用率であり、Pod の CPU Request に対する使用率ではない ということだ。今回の事例で知りたいのは「Fleet に設定した CPU Request が適正かどうか」なので、CPU Request に対する割合を出したい。

そこで、Kubernetes API と Downward API を利用して metrics exporter から自身のPodの CPU Request を取得して、割合を計算できるようにした。

apiVersion: "agones.dev/v1"
kind: Fleet
spec:
  template:

# ... (snip) ...

        spec:
          containers:
            - name: metrics
              env:
                - name: MY_POD_NAME
                  valueFrom:
                    fieldRef:
                      fieldPath: metadata.name
                - name: MY_POD_NAMESPACE
                  valueFrom:
                    fieldRef:
                      fieldPath: metadata.namespace
import (
    "context"
    "fmt"
    "os"
    "runtime"

    v1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/rest"
)

func getMyPod(ctx context.Context) (*v1.Pod, error) {
    namespace := os.Getenv("MY_POD_NAMESPACE")
    podName := os.Getenv("MY_POD_NAME")
    k8s, err := newKubernetesClient()
    if err != nil {
        return nil, fmt.Errorf("failed to new kubernetes client: %w", err)
    }
    return k8s.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{})
}

func newKubernetesClient() (*kubernetes.Clientset, error) {
    config, err := rest.InClusterConfig()
    if err != nil {
        return nil, fmt.Errorf("failed to get in-cluster kubeconfig: %w", err)
    }
    return kubernetes.NewForConfig(config)
}

type podResources struct {
    TotalCPURequests float64
}

func getPodResources(pod *v1.Pod) podResources {
    var res podResources
    for _, container := range pod.Spec.Containers {
        res.TotalCPURequests += float64(container.Resources.Requests.Cpu().MilliValue()) / 1000
    }
    return res
}

また、この例では Kubernetes APIを直接叩くため、GameServer Pod を実行する Service Account に対して Pod 情報を読み取れる権限を付けるのも忘れずに。

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: pod-getter
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: get-pods
subjects:
  - kind: User
    name: system:serviceaccount:default:agones-sdk  # namespace: default, service account: agones-sdk の場合
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: pod-getter
  apiGroup: rbac.authorization.k8s.io
---

ここまでの実装をつなぎ合わせると、次のようにプロセス毎のRequestに対するCPU使用率をPrometheusに記録できる。

func recordMetrics(ctx context.Context, procs map[int32]*processInfo, session *gamesession.Session, podResources *podResources) error {
    ps, err := getProcesses(ctx)
    if err != nil {
        return fmt.Errorf("failed to get processes: %w", err)
    }
    for _, pi := range ps {
        if !isTargetProcess(pi) {
            continue
        }
        if _, ok := procs[pi.process.Pid]; !ok {
            procs[pi.process.Pid] = pi
        }
        metrics, err := fetchProcessMetrics(ctx, procs[pi.process.Pid])
        if err != nil {
            return fmt.Errorf("failed to get process cpu usage: %w", err)
        }
        labels := prometheus.Labels{
            labelGameID:        session.GameID,
            labelSessionID:     string(session.SessionID),
            labelProcessName:   pi.processName,
            labelProcessPID:    fmt.Sprintf("%d", pi.process.Pid),
            labelPodCPURequest: fmt.Sprintf("%f", podResources.TotalCPURequests),
        }
        cpuUsages.With(labels).Set(metrics.CPUUsagePercent)
        cpuUtilizationByRequest.With(labels).Set(metrics.CPUUsagePercent / podResources.TotalCPURequests)
    }
    return nil
}

この指標をGrafanaなどのツールで可視化すると以下の画像のような結果が得られる!

ラベルにはゲームIDやプロセス名などが入っており、 どのゲームでどのプロセスが、Pod のCPU Requestsに対して何%ぐらい使っているのかが一目瞭然である。

これだけの情報があれば次の疑問すべてに答えることができる。

  • そのPodでは今何のゲームが起動しているのか?
  • そのPodの中で何の処理にどれくらいのリソースが使用されているのか?
    • ゲーム本体、エンコード処理、…それぞれの割合は?
  • それぞれの使用率は CPU Requests に対してどれくらいの割合か?

ここまでの情報が揃えば、ゲームごとに適切なCPU割り当てがわかってくる。 最初に雑に推測した2コア割当でゲームを起動してみて、2コアのCPU Requestに対する使用率を観察する。

それを繰り返してゲーム毎に使用率をまとめ、ざっくりと Low, Middle, High のように3段階ほどに分類する。そこから、それぞれの段階に対応する Fleet を作るという戦略がとれる。 また、その情報からひとつの Node にどれだけのGameServerを詰め込めるかも概算できる。

3段階程度だと、すべてのゲームにぴったりフィットする割り当てとはいかないが、大きなCPUの無駄が生じたりリソース不足でゲームが固まったりすることは結構防げるのではないかと思う。


  1. 負荷に応じてPodのリソースが伸縮したり、適切なNodeへの移動ができれば理想的なのだが、メモリの状態を維持したままのLive Migration となり技術的にも難しそうで、現在の Kubernetes にはそのような機能はない。