blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2024.06.25 インターンレポート

技術的挑戦を通して感じるDeNA [日比谷音楽祭おさんぽアプリ2024 サーバー編]

by Shunsuke Wakamatsu Shinryu Matsuoka

#hibiya-music-fes #htmx #go #google-cloud

この記事では、24 新卒、25 新卒がインターンシップで開発を行なった『日比谷音楽祭おさんぽアプリ 2024』のサーバーチームでの技術的挑戦について紹介します。

この記事の概要

  • サーバーチームでの技術的挑戦
    • Go での slog の活用
    • htmx を用いた管理画面の開発
  • インターンで感じた DeNA の文化

日比谷音楽祭 2024 とおさんぽアプリ

『日比谷音楽祭おさんぽアプリ 2024』(以下、おさんぽアプリ) は日比谷公園で 2024 年 6 月 8 日と 9 日の 2 日間で開催された 「 日比谷音楽祭 」のために開発されたアプリです。 日比谷音楽祭では 「音楽の新しい循環をみんなでつくる、フリーでボーダーレスな音楽祭」 をコンセプトとしており、親子孫3世代、誰もが気持ちのよい空間と、トップアーティストのライブやさまざまな質の高い音楽体験を、 無料で楽しむことができます。

今年のおさんぽアプリでは、参加者がイベントを回るのを楽しむために、主に AR を活用した以下の機能を提供しました。

  • 会場内に隠された宝箱を探すトレジャーハント機能
  • 目的地への経路が AR 上で表示される道案内機能

また、他にも補助機能として、以下の機能が存在しました。

  • 参加者へのアンケート機能
  • 管理画面からのプッシュ通知発行・予約機能

技術構成

サーバーサイドは 内定者 5 名(24 卒 2 名、25 卒 3 名)とサポートエンジニアの社員 1 名というチーム構成で、モバイルアプリ以外の全ての技術領域を担当しました。 具体的には API サーバー、管理画面の開発運用をサーバーチームで担当しました。 また、おさんぽアプリの開発は数ヶ月で企画からリリースまで行うプロジェクトです。 そのため、技術的なチャレンジを行う場としても活用しており、採用事例の少ない技術なども積極的に使用した技術構成となっています。

アーキテクチャ

日比谷音楽祭おさんぽアプリのサーバー・インフラの技術構成について説明します。 アーキテクチャ図を以下に示します。

アーキテクチャ図

  • インフラは Google Cloud 上に構築
  • API サーバーと管理画面用サーバー(OPE サーバー)が Cloud Run 上で稼働
    • API サーバーは Firebase の匿名認証を使用
    • 管理画面用サーバーは Identify Aware Proxy(IAP)によりアクセス制限
  • データベースは Cloud SQL for MySQL
  • プッシュ通知予約に Cloud Tasks を使用
  • Github Actions により コンテナイメージを Artifact Registry にプッシュし、Cloud Run にデプロイ

API サーバー

アプリケーション用の REST API を提供する Go で作成したサーバーです。 フレームワークには Gin を使用しており、ORM として GORM を使用しています。

管理画面

Go テンプレートと htmx を中心とした MPA で管理画面は作成しています。 htmx は、HTML を通じて DOM の置換などのモダンブラウザの機能にアクセスできるようにするライブラリです。 例として、HTML 内に以下のようなボタンタグがあった場合を考えます。

<button hx-post="/clicked" hx-target="#target">クリック</button>

このボタンをクリックされた場合、/clicked へ POST リクエストが飛びます。 そして、レスポンスとして返された HTML 要素で DOM 内の idtarget の要素が置き換えられます。 このように、htmx では HTML の attribute を通じて、イベントをトリガーとした DOM の置換、HTTP リクエストの設定を行えます。

今回、管理画面では初期状態の HTML、API のレスポンスで返す HTML 要素を Go テンプレートで記述し、Go のアプリケーションサーバーでレンダリングする構成を使用しました。 また、スタイリングは Tailwind CSS を使用して行い、htmx の補助として一部で Alpine.js を使用しました。 この構成とすることにより、ロジック部分が Go で記述できるようになり、API サーバーと共通化できています。

技術的挑戦

ここからは、サーバーサイドで行った技術的挑戦について、実現するために行った工夫、良かった点、悪かった点を紹介します。

Go テンプレートと Tailwind CSS の繋ぎこみ

管理画面で Tailwind CSS を Go テンプレートと組み合わせて利用するために 2 つの工夫を行いました。

Go テンプレートを利用しての Tailwind CSS のパージ

Tailwind CSS では設定なしでは各 CSS プロパティと設定される可能性がある値に対応したクラス全てを含む極めてサイズの大きい CSS ファイルを生成してしまいます。 このため、CSS を適用する対象の HTML ファイルを指定することで必要な CSS のクラスのみを CSS ファイルに含めることができ、本番運用ではこれを使用することが推奨されています。

今回は Go のテンプレートから生成した HTML に CSS を適用するため、このクラスのパージが正常に動作するか事前調査を行いました。 結論としては、単純な正規表現1を元に必要なクラスを識別しており、Go のテンプレートを HTML の代わりに指定しても問題なくクラスのパージが可能なことがわかりました。 どうやら Rails と組み合わせて Tailwind CSS を使用する際などにも同様にテンプレートを元にしたパージを使用するようです。 実際、今回の開発で Go テンプレートを HTML の代替として使用しても正常にパージが行われることを確認できました。

Node.js なしでの Tailwind 実行

React などの Java Script ベースのフレームワークでの開発時に Tailwind CSS を利用する場合には、Vite、Webpack などの Node.js 上で動作するビルドスタック内でプラグインとして実行し、CSS を生成することが多いと思います。 しかし、今回は Go のプロジェクト内で Tailwind CSS を利用するため、極力これらの Node.js を必要とするビルドスタックを導入したくありませんでした。 これを実現するため、今回は Tailwind CSS の Standalone CLI を利用しています。

Standalone CLI は vercel/pkg を利用して作成されたシングルバイナリの cli で、Node.js なしで CSS ファイルの生成が行えます。 以下のようなコメントを Go ファイルに記述して go generate で Standalone CLI を実行することで、Go のツールチェーン内でスムーズに Tailwind を使用できるようにしていました。

//go:generate tailwindcss -i ./input.css -o {{CSS出力ファイルのパス}} --minify

slog による構造化ロギング

昨年、QA でのバグ報告への対応に時間を取られた反省をもとに、Cloud Logging でログの検索を元にバグ報告の原因調査を行えるよう、今年の開発では構造化ログを採用しています。 Go での構造化ログライブラリには logrus や zerolog などがありますが、今回は安定性と得られる知見の多さを考えて Go 1.21 で導入された log/slog を利用しました。

Cloud Logging への対応

Cloud Logging では、構造化ログ内の severity、timestamp、message などの一部のフィールドが特別な扱いがされます。 特に severity はログレベルの判別に使われるため、非常に重要です。 一方、slog で出力されるログフィールドはこれらの特殊なフィールドと中身は同じでもフィールド名が異なるものが多くあります。 また、ログレベルの値については slog では存在する WARN というレベルが Cloud Logging では存在せず、代わりに WARNING というレベルが存在するなどの問題もあります。

そこで、今回は slog の ReplaceAttr オプションに以下のような関数を設定することでフィールド名・値の変換を行い対応しました。

func replaceAttr(_ []string, a slog.Attr) slog.Attr {
	switch a.Key {
	case slog.LevelKey:
		value := a.Value.String()
		// ログレベルの値を変換
		if value == slog.LevelWarn.String() {
			value = "WARNING"
		}

        // フィールド名を変換
		a = slog.String("severity", value)
	// 以下、timestamp、messageフィールドについても変換を行う
	}

	return a
}

GORM の SQL トレースの slog 対応

QA でのバグ報告への対応時などに活用するため、開発環境では GORM にカスタムロガーを設定することで実行した SQL を構造化ログとして出力していました。 基本的に GORM のロガーインターフェース に合わせて実装を行えば問題ありませんが、ログに出力されるソースコード内の位置について工夫を行っています。 slog ではデフォルトでソースコード内でログ出力が行われたファイル名と行数が出力されます。 カスタムロガー内で単純に slog.Info などを呼び出すとカスタムロガー内で slog.Info などの呼び出された位置が出力されてしまいますが、SQL トレースでは SQL の呼び出された位置が出力されて欲しいです。

slog のドキュメントではこのようなロガーをラップする際に runtime からコールスタックを読み取ることで、ソースコード内の位置を修正する方法が紹介されています。 今回もこれに従って、以下のような関数でおおまかな SQL の呼び出し元を取り出して対応しました。

func (l *gormLogger) gormCallerPc() uintptr {
	pcs := [13]uintptr{}
	// gorm内部の関数呼び出しを無視
	len := runtime.Callers(3, pcs[:])
	frames := runtime.CallersFrames(pcs[:len])
	for i := 0; i < len; i++ {
		frame, _ := frames.Next()
		// ファイル名ベースでこのプロジェクト内の呼び出し元を特定する
		if !strings.Contains(frame.File, "gorm.io") {
			return frame.PC
		}
	}

	// どのファイルもマッチしなかった場合、最初の PC を返す
	return pcs[0]
}

負荷試験の実施

負荷試験の目的は、本番の運用環境に近い状況でサーバーに負荷を与えることで事前にボトルネックを特定し、パフォーマンス改善をすることです。 今年は去年のアプリダウンロード数、リクエスト数などから想定負荷を算出し、ユーザーの使用ケースに基づいて各 API ごとの負荷を計測するシナリオベースのテストを行いました。

Cloud Run にあわせた計画書の作成

負荷試験を実施するにあたり、詳細な計画書を作成しました。 今回は、想定負荷下で安定運用ができるかどうかを確認する性能テスト、要求範囲を超える負荷をかけた時の挙動を確認する限界テストの2種類を実施しました。 API サーバーとして採用した Cloud Run はオートスケールするため、単純にリクエスト数を増加させるだけでは効果的なテストを行うことができません。 そのため、はじめにインスタンス最大数を 1 に設定して、そこから手動でスケーリングさせていくことで性能計測を行いました。 実際に行った際の流れを以下に示します。

  1. Cloud Run の最大インスタンス数を 1 に設定して負荷計測および調整
  2. Cloud Run の最大インスタンス数を 100 に設定して負荷計測および調整
  3. スパイクへの耐性確認

計測したメトリクス

クライアント側の指標

負荷試験ツールには Locust を採用しました。採用理由は容易にシナリオベースのテストを実施可能かつ、デフォルトで Web ベースの豊富な可視化機能を提供していたためです。

クライアント側では以下のメトリクスを計測しました

  • 1 秒あたりのリクエスト数
  • レスポンス時間
  • ユーザー数

Locust による負荷試験の様子

サーバー側の指標

サーバー側では以下のメトリクスを計測しました。

  • Cloud Run
    • CPU・メモリ使用率
    • インスタンス数
    • 最大リクエスト数
  • Cloud SQL
    • CPU・メモリ 使用率
    • ディスク I/O 時間
    • クエリ実行時間
  • Cloud Profiler
    • CPU、メモリのプロファイル Cloud Profiler によるプロファイルの可視化

パフォーマンスの改善

負荷計測の結果を踏まえて、パフォーマンスの改善を行いました。 ここではいくつかを抜粋して紹介します。

SQL クエリの修正

高負荷時に COUNT 関数を用いると実行時間が大幅に長くなっていることがわかりました2。 そこで JOIN 句や Go で書いた関数を活用して COUNT 関数を使わないように修正することで、パフォーマンスを改善しました。

インスタンスあたりの最大同時リクエスト数の増量

最大同時リクエスト数がボトルネックとなり、CPU やメモリ使用率、DB のコネクションなどインスタンスのリソースが余っていました。そこで最大同時リクエスト数を 1 インスタンスあたりの最大スループットまで増やし、インスタンスリソースを最大限活用できるようにしました。

インターンに参加して

ここからは、私たちがインターンに参加して感じた DeNA の雰囲気を紹介します。

技術的挑戦を後押ししてくれる環境

今回、管理画面では採用事例が少ない htmx を中心とした挑戦的な技術選定を行なっています。 この技術選定は 25 卒のメンバーが提案したものですが、提案した時点では最初は挑戦としての価値は感じつつもリスクが大きいことから、シンプルな React の SPA など他の技術を採用しようとしていました。

しかし、24 卒のメンバーからの後押しにより最終的に htmx を使用することができました。 これによって、初めてテンプレートエンジンを使用した構成を触ることで、React などとの考え方の違いやメリット・デメリットを体験することができ、成長に繋がりました。 このような技術的挑戦を後押ししてくれる環境があることは非常に良いと思いました。

インターン生中心のチームだからこそ生じる責任

記事冒頭で触れた通り、今回のプロジェクトでは私たち内定者インターン生が中心となって取り組みました。そのため、非常に多くの意思決定がインターン生に委ねられました。 App チームはもちろん、PdM やデザイナー、品質保証(QA)チームとのやりとりに至るまで、サーバーサイドを担当するエンジニアとして責任を持ってコミュニケーションを取る必要がありました。 また、当初のスケジュール通りにプロジェクトが進行していなかったということもあり、自分たちで優先順位を決め、工数を見積り、スケジュールを引き、それに基づいて取り組むといったことをしました。

つまり、ただ言われたとおりの実装するだけでなく、高い主体性・責任を持ってプロジェクトに関わる必要がありました。 これはまさに DeNA が掲げている DQ (DeNA Quality) を実感できる、非常に貴重な経験になったと考えています。

社内技術コミュニティ・勉強会

DeNA には Go や Web フロントエンド、Flutter などの多様な社内技術コミュニティが存在するほか、小規模な勉強会なども頻繁に行われています(詳細: https://engineering.dena.com/culture/study/ )。 これらのコミュニティや勉強会を中心として、技術に関する新しい知識を得やすい環境が整っているのが非常に良いと感じました。 また、インターン期間中にこれらの勉強会に参加する中で、技術以外にもこれらを通じてチーム外とのつながりをスムーズに得ることができるという良さも感じました。

おわりに

この記事では、日比谷音楽祭おさんぽアプリの開発で行った技術的挑戦やインターンで感じた DeNA の文化について紹介しました。 裁量が大きく技術的挑戦を後押ししてくれる環境だからこその非常に良い経験が得られたインターンだったと思います。

興味ある方は、是非 DeNA のインターンに参加してみてください。


  1. https://v2.tailwindcss.com/docs/optimizing-for-production#writing-purgeable-html ↩︎

  2. この問題は MySQL 8 系のバグだったことがわかっており、2024 年 4 月にリリースされた MySQL 8.0.37 では解消されているようです。 詳細: https://bugs.mysql.com/bug.php?id=97709 ↩︎

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。