Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

ATLASチームにおける就業型インターン参加レポート

こんにちは。

株式会社アカツキゲームスで ATLAS というチームに所属してゲーム内通貨管理基盤の開発及び運用を行っています、なかひこくん (@takanakahiko) です。 最近は良い気候なので、バイクでの運転が気持ち良いですね。

私の担当するゲーム内通貨管理基盤の開発現場で、インターン生を受け入れました。 ありがたいことに、そのインターン生がブログ向けに参加レポートを書いてくれたので私の方から代理投稿させていただきます。

上記の通り、この記事は代理投稿となります。 投稿者と執筆者は異なるのでご承知ください。


はじめに

こんにちは。 2023/9/11 〜2023/10/12の期間、週3日ほどのペースで株式会社アカツキゲームスのサマーインターンシップに参加させていただきました、高橋(@TAK848)です。 このたび、ATLAS というチームに所属してゲーム内通貨管理基盤の開発チームにてインターンさせていただきました。 今回は、インターンシップで取り組んだことや感想などをまとめていきたいと思います。

経緯

3月に参加したサポーターズの1on1経由でインターンシップにお誘いいただいたことがきっかけです。

自分としては、業種にこだわりはあまり持っていないからこそ、インターン等を通じて様々な業種・開発環境や文化を知ることで、キャリアを考えていく上でも勉強になると良いなという考えで、夏以降のインターンを探していました。

技術的側面で言うと、Go・GCPで作られた基盤のチームということで、自身がGo中心にサーバー等のコードを書きつつ、インフラを広く触ってきていたのもあり、複数プロダクトで使われている決済基盤ということで、アプリ・インフラ・パフォーマンスチューニングなど、広く学ぶことができそうだなと思い、志望させていただきました。

過程で日程の調整を何度も受け入れてくださり、非常に申し訳ないながらもありがたかったです。

取り組んだ課題

主に、3つのタスクにとりかかりました。

  1. 不要なパラメータ削除
  2. Datastoreからのデータ取得のパフォーマンス改善
  3. とあるエンドポイントのレスポンスバグ修正対応

以下に詳細を記述します!

1. 不要なパラメータ削除

初日の環境構築等が終わり、チームの開発フローを知る目的で最初に取り組んだ簡単なタスクです。

構造体から不要なパラメータを削除する簡単なタスクでしたが、Layered Architectureによって、controller・domain・repositoryに綺麗に分割され、個人的にあまり使ってこなかったinternalも用いて責任が明確になっていて、とっつきやすかったです。

単体テスト・統合テストもきちんと書かれており、安心してmergeできる環境で、テストの大事さを感じました。(個人でハッカソンなど参加していると、時間を言い訳に飛ばしがち…)

チームの雰囲気として、良い意味でドライではありながら褒め合うよな文化もあり、とてもあたたかかったです。

2. Datastoreからのデータ取得のパフォーマンス改善

こちらがメインタスクになります。ATLASの決済基盤では、AppEngineとの歴史的経緯もあり、Datastoreを用いています。

現状、domainのとある関数において、

a, err := aRepo.GetA(tx, /* 省略 */)
// 省略
b, err := bRepo.GetB(tx, /* 省略 */)
// 省略
c, err := bRepo.GetC(tx, /* 省略 */)

のように、複数のデータを取得する際に、repositoryから1つずつ取得していました。ですが、APIの処理において、Datastoreへのアクセスが処理時間の多くを占めていることが既知で、このアクセスに非常に多くの時間を占めているのでは?という仮説がありました。

これのパフォーマンス改善案として、NoSQLであるDatastoreでは、複数のデータをまとめて取得するGetMultiという関数が用意されているので、これを使うように変更した上で、速度を比較してみることになりました。

呼び出し後の変更後のコードは以下のような感じです。

keys := []xxx.YyyKey{
    // 省略
}
a := &types.A{}
b := &types.B{}
c := &types.C{}
dst := []any{
    a,
    b,
    c,
}
err := xRepo.GetMulti(tx, keys, dst)

なお、xRepo.GetMultiの内部で、さらにdatastoreのライブラリのGetMultiを呼び出しています。

書き換え時の課題

domainとrepository間のデータのやり取り

この基盤は、以下の図のように、Layered Architecture構成になっています。

アーキテクチャ

上述のコードのdstにあるABCは、アプリ共通のtypesの構造体です。一方、datastoreのGetMultiでは、dstypesの構造体のポインタを渡してあげる必要があります。

呼び出し元の関数からdstypesの値を直接渡せれば、この場限りでの実装では一番楽かもしれませんが、internal配下できちんと分離されている上、依存関係が複雑になってしまうのでやはり避けたいです。

一時期は、func GetABC(/* 省略 */) (*types.A, *types.B, *types.C, error)のような関数を作って、typesを直接返す即席の関数を作ることも考えましたが、

  • aRepobRepoのように、repositoryごとでも種類ごとに責任を分割されているのに混ざり合ってしまうこと
  • 今後もこのようにGetMultiに統合して、一度に値を取得する修正をするケースも出てくるであろうこと
  • そのパターンごとにメソッドを生やしてしまってはキリがないこと

などもあり、やはりこれも避けたいと考えました。

このことから、xRepo.GetMultiの引数のdst []anyには、types配下の構造体のポインタのスライスを渡し、xRepo.GetMulti内で、引数のdstの型をチェックしながら、データ取得用にdstypesの値を用意してあげて、datastoreのGetMultiを実行、最後にdstのポインタに格納してあげる、という方法を取ることにしました。

結果的には、以下のようなコードになりました。

dsDst := make([]any, len(dst))
// types -> dstypes
{
    for i, d := range dst {
        switch d.(type) {
        case *types.A:
            dsDst[i] = &dstypes.A{}
        case *types.B:
            dsDst[i] = &dstypes.B{}
        case *types.C:
            dsDst[i] = &dstypes.C{}
        default:
            return nil, errors.New("invalid type")
        }
    }
}

err := (*datastore.Transaction)(tx).GetMulti(keys, dsDst)
if err != nil {
    // 省略
}

// dstypes -> types
{
    for i, d := range dsDst {
        switch dsDstItem := d.(type) {
        case *dstypes.A:
            if typesA, ok := dst[i].(*domaintypes.A); ok {
                *typesA = *dsDstItem.ToDomain()
            } else {
                return nil, errors.New("failed to GetMulti() cast error")
            }
        // 省略
        }
    }
}

dst[]any)の中のポインタの値を変える時に、*typesA = *dsDstItem.ToDomain()のように書かないと、新しいポインタが生成されて、別のものに置き換わってしまい、呼び出し元のポインタには反映されないということがあり、ポインタ周りをしっかり把握して実装するのもなかなか大変でした。

ちなみに、dstypes -> typesの変換ですが、ToDomain()を持つinterfaceを用意してあげれば、switch文を使わずに変換できるようになるので、さらに拡張性が上がるかなと思います。もっと時間があれば是非やってみたかったです。

datastoreのGetMultiのエラー型

今までは、各リポジトリの、特定の構造体を取得するメソッドごとに、独自のエラーハンドリングができましたが、GetMultiは、datastore.MultiErrorという特殊な型で返ってきます。

このため、GetMultiを呼び出すxRepo.GetMultiの内部で、datastore.MultiErrorをハンドリングする必要がありました。

最終的には、以下のようにエラーをハンドリングすることになりました。また、呼び出し元で、そもそも値が存在しない場合のハンドリングが必要であったこと、この場合も正常な挙動であることから、isNoSuchEntityというスライスを用意し、個別でハンドリングするようにしました。

err := (*datastore.Transaction)(tx).GetMulti(keys, dst)
isNoSuchEntity := make([]bool, len(keys))
if err != nil {
    merr, ok := err.(datastore.MultiError)
    if !ok {
        // datastore.MultiError ではない場合はそのエラーを返却
        return nil, err
    }
    for i, e := range merr {
        if exerrors.Is(e, datastore.ErrNoSuchEntity) {
            isNoSuchEntity[i] = true
        }
    }
}

こちらの記事が参考になりました。

https://qiita.com/hanenao/items/b25bc3ec274380f8633a

速度計測

ここまでで、GetMultiにより、3回の取得リクエストを1回に統合することはできましたが、計測してみないとこれがそもそも有効な改善なのかどうか、インパクトはどのくらいなのかがわかりません。そのため、GetMultiを反映した状態と、未反映の状態を用意して、負荷試験を行いました。

GKE環境で負荷試験を行うためのテンプレートが用意されていたので、これを利用させていただきました。ツールとしては、Pythonのlocustというものを使いました。

そのまま実行しようとしたところ、

  • バージョンの違いでそもそも動かない
  • 負荷試験用のworkerのためのNodeがスケールしてくれず、負荷をかけるとNodeの応答がなくなる

といった問題も発生しましたが、Kubernetesについての知見をある程度もっていたこともあり、kubectl describe/logsなどによるデバッグ、helm install時に使用するvalues.yamlresourcesのCPU・メモリ要求を追加するなどすることで、無事動くようになりました。

結果として、具体的な数字は出せませんが、中央値で約20%ほどの改善が見られました。

自分の想像以上にパフォーマンスが改善されてインパクトがあることがわかり、驚きと共に素直に嬉しかったです。

リリース後のパフォーマンス

インターン期間中に、本番環境にまでリリースいただくことまでできました。 メトリクスを見せていただいたのですが、きちんと計測通りかそれ以上に速くなっており、中央値で25%ほど高速化されていました。 また、大きなバグも発生しませんでした。

以下は、モニタリングダッシュボードで POST リクエストにおけるレイテンシの 50%ile の推移を記録したものです。 縦軸の記載はありませんので分かりづらいですが、グラフの最下部が0ではないことに気をつけてください。

モニタリングダッシュボードのスクリーンショット

Slackやチームのデイリーでも、あたたかく喜んでいただいたり褒めていただいたりと、嬉しかったです。

とあるエンドポイントのレスポンスバグ修正対応

最後に取り組んだタスクです。

エンドポイントの機能を修正しました。

プレイヤーの無償通貨の残高を増やすエンドポイントがあるのですが、特定の条件で、このリクエストに対するレスポンスで、有償通貨の残高が0として返されてしまうという問題がありました。 この問題によって実際のプロダクト(ゲーム)において問題は発生していないのですが、今後の開発者同士の認識のズレなどによる事故を防ぐために、バグのあるエンドポイントを非推奨にし、別のエンドポイントの機能を拡張するような変更を行いました。

追加した機能部分の統合テストも書き、変更箇所を呼んでいる経路なども確認して万全を期していたつもりだったのですが、予想外の挙動をしているエンドポイントが1つあり、開発環境で一時的に他のチームにご迷惑をおかけすることになってしまいました。

ですが、すぐにバージョンを戻した上で、原因を特定することができ、修正が完了しました。

想定外のバグが出てしまった箇所に関しては、テストが不足していたのもあり、自分が確認した段階でも完全に抜けてしまっていたのが痛かったです。 テストが抜けていることがあると、どこかいじった時に予想外のバグが発生してしまうのは、テストが通ったからとはいえ、完全に信頼はしきらずに気をつけないといけないなと思いました。

とはいえ、これで開発環境で一時的にご迷惑をおかけすることになってしまいましたが、責めるようなことはなく、チームのみなさんともに、なぜこのようなことが起こったのかをみながら、仕組みを直して次につなげていこうというあたたかい姿勢で対応くださりました。

感想

会社の雰囲気など

個人的に、リモートが多いながらも、オフィスが土足禁止で意外と快適だったり、ラウンジが充実していたりと気軽に出社できるような環境になっていて、各人が自分の好きなスタイルで働けるような環境になっていて良いなぁと思いました。

会社やキャリアの話をメンターの方や、チームの方、新卒の方などからお伺いすることができ、とても勉強になりました。

チームの雰囲気として、チームの皆さんが意見を言いながらも、相手を否定することはなくポジティブに物事を捉えて進めていき、「良いですね」「素晴らしい」などと褒め合うあたたかさがありました。また、何かあったときは責めたり否定したりせず、冷静になんでこうなったかなどを考え、仕組みを作るなど次に生かしていく姿勢がとてもあたたかくてやりやすく、僕も見習っていきたいです。

技術面

最近Go公式ドキュメントで、ディレクトリ構成の見解 が出ましたが、これを早速取り入れるなど、全体的に読みやすさやとっつき易さ、メンテナンスコストなどを下げる取り組みがなされていて、非常にモダンな構成で勉強になりました。

そんな中で、キャッチアップをしながらプロダクトで非常に多く呼ばれる箇所のパフォーマンスを20%もあげられて嬉しかったです。

Golangでバリバリちゃんと書かれたAPIを見て勉強できる機会って意外と無くて、技術的にもめちゃめちゃ学びが多くてありがたかったです。個人で触ると(お金的に)怖いGKEについても、ただ書いてあるコマンドを打つだけでは無くて、しっかり動かすためにどうすれば良いか考えながら触ることができてありがたかったです。

最後に

ここまで関わってくださった全ての方々にお礼申し上げます。5週間の間、ありがとうございました!!