🌯

【個人開発】Next.js 13 (App) × GCP × Cloudflare × microCMSを駆使してブログをリニューアルする

2023/11/01に公開2

はじめに


私は https://rl-japan.com というサイトを運営しています。
これは「ロケットリーグ」というゲームに関するニュースや攻略情報をまとめたサイトです。
日本において、このゲームの情報は限られているため、2021年よりロケットリーグ専門の情報ポータルサイトとして運営を開始しました。現状、ページビュー(PV)やユニークユーザー数(UU)も安定しており、コミュニティへの貢献を実感している今日この頃です。

プロジェクトの背景と目的

旧RL Japanトップページのサムネイル
以前の「RL Japan」のトップページでは、microCMS様が公式で提供しているオープンソースのテンプレート[1]を採用していました。このテンプレートは複製・改変・商用利用が可能で、ブログ公開に必要な機能が網羅されているため、すぐに公開でき非常に助かりました。技術的にはNuxt.js (Vue2) と Netlify/Netlify Functionsを組み合わせて使用し、デプロイフローもNetlifyに頼っていました。SSGの活用により、高速にブログを閲覧することができる環境でした。

しかし、近くVue2がEOL[2]を迎える予定であることや、NetlifyのOrganizationアカウントに関する仕様変更[3]に伴う不透明感が生じてきたため、技術のアップデートとともにNetlifyからの依存脱却を図ることにしました。さらに、新機能の追加やデザインの改善もより容易に行える環境を整えるために、再開発/リニューアルの方針を固めました。

使用する技術スタックの概要


個人開発を進める上で、以下3つを要点に技術選定を行いました。

  • コストを抑えやすい(安い)
  • 複雑な手順や設定なく素早く始められ(早い)
  • それでいてモチベーション・パフォーマンスに期待のできる技術(美味い)

フロントエンド

バックエンド/BFF/API

  • Node.js
  • microCMS
  • Twitch API
  • Google Analytics Reporting API

インフラ

計測・その他

Next.js 13 (App Router) のセットアップ

Next.jsの新機能: App Router

今回、Nuxt.js(Vue2)からNuxt.js(Vue3/Composition)への移行を検討したものの、代わりにNext.js13を基盤としてフルスクラッチで開発を進めることにしました。
Vueを個人的に好きであるとはいえ、今後複数人での開発もしていきたいことや、業務でのNext.jsの経験や新機能のApp Routerの魅力が強く、Next.jsを使用したプロジェクトを立ち上げる決意をしました。

多くの方がNext.jsやApp Routerの利点について詳しく触れている記事を執筆していると思いますが、私の経験として、AppRouterを使う初期段階は学習コストがかかりますが、クライアントとサーバーの境界を明確に定義できる点が非常に魅力的でした。圧倒的にuseEffectの沼に落ちることが少なくなったと感じています。この利点については、採用して正しかったと感じています。ただし、大規模なプロダクションや複雑なアプリケーションを構築する際には十分な注意が必要だとも感じています。

開発環境の構築

開発環境の構築に際し、Cloud Run上でのアプリケーション運用を目指し、Next.jsのdockerテンプレートを採用しました。しかし、このテンプレートはPagesRouterを基盤にしています。そのため、最初のステップとしてAppRouter向けのファイル構造に変更する必要があります。具体的な内容は後続で詳しく紹介します。
https://github.com/vercel/next.js/tree/canary/examples/with-docker

基本的なルーティングとページ構造

├── src
│   ├── app
│   │   ├── (default)
│   │   │   ├── about
│   │   │   └── privacy-policy
│   │   ├── (sidebar)
│   │   │   ├── articles
│   │   │   │   └── [slug]
│   │   │   │       └── page.tsx
│   │   │   ├── calendar
│   │   │   ├── category
│   │   │   │   └── [categoryId]
│   │   │   │       ├── [pageId]
│   │   │   │       │   └── page.tsx
│   │   │   │       ├── loading.tsx
│   │   │   │       └── page.tsx
│   │   │   ├── layout.tsx
│   │   │   └── search
│   │   ├── api
│   │   │   ├── draft
│   │   │   │   └── route.ts
│   ├── components
│   ├── constants
│   ├── middleware.ts
│   ├── services
│   ├── store
│   └── utils

※ 一部省略しています
App Routerを利用する際、srcディレクトリ(あるいはプロジェクトのルートディレクトリ)に/appというディレクトリを作成し、そこでルーティングを管理する必要があります。また、特定のファイル名、例えばpage.tsxやloading.tsxなどは、特別な意味や役割を持っています。

現時点ではAppRouterでの最適なルーティング方法は確立されていないように思いますが、Layoutのような要素をRoute Groups(形式としては (dir) など)でまとめると、管理がシンプルになると感じています。一方で、コンポーネントの整理方法についてはまだ最適な方法を見つけられていません。もし、読者の方でより良い方法をご存知のかたがいれば、フィードバックを頂けるとありがたいです。
詳細なルーティングに関する情報は、Next.jsの公式ドキュメントで確認できます。
https://nextjs.org/docs/app/building-your-application/routing

middlewareの用意

旧来のサイト設計における問題点として、記事詳細ページが/ルート直下でアクセスされる構造であったため、記者が既存のページのパスと意図せずに重複させることができ、ページパスの命名に制限がかかるという問題が生じていました。今回、既存のコンテンツのSEOを維持しつつ、コンテンツをスムーズに移行するため、middlewareを利用して308リダイレクト[4]を実装する工夫を行いました。

SEO対策

  • JSON-LD
  • sitemap.xml
  • robots.txt
  • feed.xml
  • generateMetaData
  • OGP

特にJSON-LDにおいては、記事に「NewsArticle」、画像に「ImageObject」を付与することで、SEOの評価を向上させる工夫を行っています。
また、SEO関連でHTMLをセマンティックな構造にすることも取り組んでいます。
Metadataに関する詳細は、Next.jsの公式ドキュメントで確認できます。
https://nextjs.org/docs/app/api-reference/file-conventions/metadata

パフォーマンス・アクセシビリティ

デスクトップパフォーマンス
携帯電話パフォーマンス
Webサイトの表示速度やCore Web Vitalsの指標を達成するための最適化を行いました。上記の画像は、それぞれデスクトップとモバイル端末でのパフォーマンススコアを示しています。デスクトップでは最高のスコア100を達成していますが、モバイルではGoogle AnalyticsなどのサードパーティのJavaScriptの影響でスコアが低下しています。

これらのパフォーマンス指標を継続的に監視・改善するため、Sentry、Firebase Monitoring、GitHub Actionsなどのツールを利用して指標の取得を計画しています。

計測にはGoogleChromeLabsのAutoWebPerfが良さそうでした。
https://github.com/GoogleChromeLabs/AutoWebPerf

microCMSでのコンテンツ管理

Next.jsとmicroCMSの連携

すでに既存のAPIとコンテンツモデルを持っていましたが、microCMSはREST APIを通じて提供しており、型定義が直接提供されないため、microcms-ts-sdkを導入し、プロジェクト内で型定義を利用可能にしました。

import { MicroCMSImage, type MicroCMSRelation } from "microcms-ts-sdk";
import { Authors } from "./authors";
import { Categories } from "./categories";
import { Partners } from "./partners";

export type Blog = {
  title: string;
  category: MicroCMSRelation<Categories>;
  toc_visible: boolean;
  body: string;
  description: string;
  ogimage: MicroCMSImage;
  writer: MicroCMSRelation<Authors>;
  partner: MicroCMSRelation<Partners>;
  related_blogs: MicroCMSRelation<Blog>[];
};

上記のコードは、エンドポイントごとに型を簡単に定義し、型チェックのサポートを受けることができます。このSDKのおかげで、開発効率が大幅に向上しておりとても便利です。tsuki-labさんにこの場を借りて感謝します。
https://github.com/tsuki-lab/microcms-ts-sdk

Next.jsのキャッシュ制御やrevalidateの恩恵を受けるためカスタムのラッパーを作成しています。

export async function getArticleBySlug({
  slug,
  dk,
  query,
}: {
  slug: string;
  dk: string;
  query?: MicroCMSQueries["articleQuery"];
}) {
  const data = await microcmsClient
    .getListDetail({
      endpoint: "blog",
      contentId: slug,
      queries: { depth: 1, draftKey: dk, ...query },
      customRequestInit:
        dk === ""
          ? {
              cache: "no-store",
            }
          : {
              next: { revalidate: 3600 },
            },
    })
    .catch(notFound);
  return data;
}

このラッパーは、下書きの記事をリクエストする際にはキャッシュを使用せず(no-store)、公開されている記事の場合は、1時間(3600秒)ごとに内容を再検証(revalidate)する設定にしています。

Google Cloud Platform (GCP) でのバックエンドとビルド管理

GCP (Google Cloud Platform) を主要なバックエンドとして採用しました。AWSも選択肢として検討しましたが、AWSの権限管理やネットワーク設計が難しく、GCPについてはガッツリ触ったことがなかったことや、UIが慣れ親しんでいたこともありGCPを選択しました。

Cloud Runについて

Cloud Runのメリットは完全にサーバレスで、使った分だけの料金を払えば良いところです。アクセスが0であれば0円で運用可能です。また、オートスケーリングしてくれるので、突然のアクセス集中やバズの機会も逃しにくく、常に最適な料金で運用できるというメリットがあります。さらに、コンテナを用意してるだけなのでベンダーロックインのリスクも低いと考えます。

しかし、適切な設定がされてないとオートスケーリングが過度に行われ、コストが増加するリスクがあります。高効率でアプリケーションを運用するためには、リソースの設定やスケーリングの設定の最適化が求められます。

k6というオープンソースの負荷試験ツールを使用して、最適なリソースの設定を行いました。負荷試験のシナリオは以下のようになっており、MAUが2000のサービスであるため、200VUsを処理できれば十分な容量が確保されていると考えました。
https://k6.io/open-source/
負荷試験のシナリオはChatGPTに作成してもらいました。

import http from "k6/http";
import { sleep, check } from "k6";

export let options = {
  stages: [
    { duration: "2m", target: 50 }, // ベースライン: 50 VUs
    { duration: "5m", target: 200 }, // ピーク負荷: 200 VUs
    { duration: "2m", target: 200 }, // 持続的な高負荷: 200 VUs
    { duration: "2m", target: 0 }, // 徐々に負荷を減らす
  ],
};

export default function () {
  let res = http.get(
    "https://example.com/articles/some-news-title"
  );
  check(res, {
    "is status 200": (r) => r.status === 200,
  });
  sleep(1);
}

負荷試験をk6で行った際のCLIの様子

MAUは2000程度のサービスなので、200VUsぐらいが捌けるなら楽勝ですね 👀
約11分で約7万リクエストを1つのコンテナで捌けるみたい[5]なので、突然のバズにも耐えられそうです 🙆‍♂️
実際にはCDNが捌いてくれているところもありますが、そちらは後述します。
もっと丁寧にやるのであれば、Breakpoint testsを行いコンテナやCPUのさらなる最適化を行うとよさそうです。

検証は行なったといえ、個人の財布で運営してるので怖いのは請求書です。
GCPのコンソールから「お支払い > 予算とアラート」を設定しておきます。

アラートを出すしきい値が設定できるので、急に高額請求が来ることはなくなります。
※予算を超えてもサービスは停止することはありません。
https://console.cloud.google.com/

Cloud Functionsの利用


新しい機能「総合ランキング」と「現在のコミュニティ配信」をNode.jsのREST APIとしてCloud Funtions上に開発しました。これはTwitch APIやGoogle Analytics APIなどのサードパーティAPIと連携するためのもので、フロントエンドをよりシンプルにするBFFのような役割を果たします。

Cloud Buildでのビルド自動化

CLIを使ってGoogleCloudSDKを導入する方法もありますが、今回はGUIで行う方法を紹介します。

  • GCPのGUIでCloud Runのページを開く。
  • サービスの作成 > 「ソースリポジトリから新しいリビジョンを継続的にデプロイする」を選択
  • 「Cloud Buildの設定」タブから「リポジトリプロバイダ」を設定
  • 必要なブランチ(例: main、production、devなど)を設定

これだけでGithubからの継続的デプロイができます。
Githubで特定のブランチにpushしたらデプロイできるのでデプロイフローも非常に単純ですね。

Firebase HostingとGlobal Cacheの設定

「急にFirebase出てきたな...」「HostingとCloudRunで競合してるじゃん」と思いの方もいると思います。今回はFirebase Hostingを純粋なホスティングではなく、グローバルキャッシュとして利用する方法についてです。
SSRですし、オリジンですべて捌き切りたいところですが、コスト面の問題がありオリジンへの負荷を減らすことが求められます。また、選定基準としてはCloud Load Balancing & Cloud CDNの組み合わせもありますが、ランニングコストを減らしたいこともありFirebase Hostingの採用を行いました。
https://firebase.google.com/docs/hosting/cloud-run?hl=ja

Firebase Hostingの基本設定

Firebase Hostingは360Mb/dayまでの無料枠と、それを超える1GBごとに$0.15がかかるシンプルな料金体系が魅力です。
皮算用でしかないですが、今回のサイトは画像や動画などのメディアはすべてmicroCMSの画像APIでホストしていることもあり、そこまでコストは増大しないと思っています。今後コストが想定以上に増加すれば、CloudCDNやCloudflareへの移行も考えています。
また、コンテナとCDNのコストどちらが高効率かも含めて考える必要があります
https://zenn.dev/catnose99/scraps/ffdd08cebfad12

Global Cacheの利用でパフォーマンス向上

CloudRunでコストを抑えるためのポイントは、インスタンスを0に保つことです。
しかし、インスタンスが0だとインスタンスを起動する際にコールドスタートによるアクセス遅延が生じ、1000-2000ms程度待機が発生します。これを解消するためにCDNのキャッシュサーバーを活用して、コンテンツを高速にユーザーに届けることができるようになります。

今回の要件は、ブログや「配信」などの鮮度が必要なコンテンツを提供しながら、オリジンへのアクセスは最小限に抑えることが求められます。
そのため、以下のようなCache-Controlの設定を行いました。

"value": "public, max-age=90, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400"

具体的な設定内容としては、クライアントのキャッシュ持続時間を90秒、CDNのキャッシュ期限を300秒とし、期限切れ後も60秒間は古いコンテンツを参照しながら新しいコンテンツへの更新を行うよう設定しています。さらに、サイトの障害時は古いキャッシュを24時間利用できるようにしています。
https://github.com/RocketLeagueJapanDevelopersCommunity/rljp-cloud-firebase

今回の設定値は、負荷試験や競合のさまざまなサービスを調査し調整した値であり、実際のユーザー環境やアクセス分布によりキャッシュ戦略の有効性が変化してくると思っています。このあたりは実際の運用を通して見直しを行なっていく予定です。

Cloudflare

今回の構成ではCloud FunctionsとCloudflare Workersの似たサービスの両方を使っています。以下の特徴は、コストを抑える開発者にとって非常に魅力的です。

  • 1日10万リクエストまで無料
  • 組み込みキャッシュ
  • リクエストやレスポンスの高い柔軟性
  • デプロイ・開発の手軽さ
  • 高速なレスポンス

Cloudflare WorkersとGoogle Cloud Funtionsを併用することで、それぞれの長所を最大限に活用し、効率的にインフラを構築できると考えています。

Cloudflare Workersの活用


Cloudflareでは5つのWorkersを運用しています。
バックエンドに複雑なAPIやロジックを持たせ、フロントエンドのロジックを最小限に保つことを意識しています。
主要な機能としては:

  • microCMSの画像APIやSearchAPIのキャッシュ&制御
  • TwitchAPIやGoogleAnalyticsAPIのラッパー機能
  • Cloudflare D1やRankingAPIを通じたおすすめ記事の提供

これにより、フロントエンドは単純なレスポンスの型定義とAPIの呼び出しを行うだけで、共通フォーマットでの結果取得が可能になっています。

Cloudflare D1の導入背景


Cloudflare D1を主に利用しているのは今回実装した「リアクション」機能です。
この機能では、記事を読むユーザーからのフィードバックや、記事のトレンドに基づく推薦情報を収集するためにCloudflare D1を利用しています。Cloudflare Workersを介してリクエストされるデータは適切に加工され、DBで処理されます。フロントエンド側は、このデータの提供や取得だけを担当しています。

モニタリングと分析

Sentryでのエラー・パフォーマンス測定

Sentryではエラーのトラッキングとパフォーマンス測定などさまざまな指標の測定が行えます。
アプリケーションでエラーが発生した際に通知されたり、ユーザー環境やエラーの内容、SSRでのエラーも取得可能です。また、DatadogでいうSessionReplayも使えます。

アラートルールの活用事例として「ユーザーが24時間いないに5人エラーに遭遇する」でエラーを開発陣に通知するを行なっています。

また、リリースビルドごとの問題発生数を観測することで、どのリビジョンからエラーが発生しているかが特定できるため、特定した時点で元のリビジョンへCloudRunのトラフィックを差し向けることもできます。
https://sentry.io/welcome/

Google Analyticsでのトラフィック分析

Google Analyticsは導入している開発者も多いと思いますが、弊ブログでは以下の用途で活用しています。

- DAU/MAUの確認
- PV数の測定
- コンバージョン測定(コミュニティDiscord参加等)
- 人気のある記事・トレンドや検索されているニーズのある記事の探索

実際の開発には、レポートのテクノロジー概要からモバイル66%でデスクトップ32.4%、タブレットが1.6%ということだったり、ユーザー画面解像度からレスポンシブ対応はモバイルの見た目優先で組んだりと情報を活用しています。

分析結果から定着率が低いこともわかっているので、「ブログ」ではなく、ユーザに継続的に使ってもらえる「メディア」や「アプリ」としての方向性を考えるなど、プロジェクトの方針を決めるためにも活用しています。

GCPでの稼働時間チェック


アプリケーションとしての監視はSentryに任せており、CloudRunのコンテナの死活管理についてはGCPのCloud Monitoringを活用しています。
今回の設定では15分ごとに /api/v1/health エンドポイントがステータスコード200を返すことを確認しています。もしレスポンスに10s以上かかっている場合にはSlackに通知するようにしています。(GCPのwebhook、気軽にDiscordにも流せるようにしてほしい...)

デザイン


デザインはGoogleAnalyticsの分析結果からスマートフォンに最適化しており、競合(競合というのもおこがましいですが...)ニュースサイトの Yahoo!ニュースを筆頭に40サイト程度の傾向を参考にしながら最適なデザインを探っていきました。
既存のサイトの問題点は、1コンテンツの画面に対する比率が高く、ユーザーが記事を発見するのが大変なところでした。今回はリスト形式にし、スマートフォンでのUXを高めるべく工夫しました。
なお、Figma等は用意しておらずに実装しています。(なんどかデザイン刷新してるので、用意しておけばよかったと後悔はしています。)
見えるコンテンツ量は多く、使いやすくもなったと思いますが、逆にゴチャってる感は否めないのでレイアウトやコンテンツの見せ方は今後も改善していければと考えています。
https://github.com/RocketLeagueJapanDevelopersCommunity/rl-japan.com/issues/41

まとめ

プロジェクトの振り返り

Next.js 13でのフルスクラッチしたことにあわせて、tailwindcssの導入とJavaScriptからTypeScriptにしたことでコンポーネントやAPIの設計と記述が速くなり、かなり作業効率があがったように感じます。また、レスポンシブデザインや単純なAppRouterの構成の援護もあり、これからは新機能を爆速で、気軽に実装できそうです。

学んだ教訓と今後の展望

今回は4月にリポジトリを作成し、そこから何度かモチベの上下をへて、7ヶ月かけてリリースにこぎつくことができました。1ヶ月ぐらいGCPのCloudBuildが全然通らなくて萎えてた時期や、デザインが決まらなくて悩んだ時期や記事リストのコンポーネントの複雑性が上がってめちゃくちゃになってた時期もありますが、これからもユーザーに有益でコミュニティが広がっていくようなサイトを目指して改善を加えていきたいです。

今後の展望は以下です。

  • インタラクティブなコンテンツやページに訪れたくなるような機能を増やす
  • よく使われる機能/システム・パフォーマンスの強化/最適化
  • レコメンドの強化
  • Storybook/Jestの導入
  • OGPの自動生成

いいねで応援よろしくお願いします 🙏

参考リソース

関連するコミュニティとブログ記事

  • rljp.dev
    ロケットリーグを中心にWeb開発・グラフィック/UIデザイン/映像制作・大会運営/大会システム作成などを行っている団体です。
  • SuperCollider
    ロケットリーグのイベント団体です。オンライントーナメント・オフラインイベントなどを定期的に開催しています。
  • RL Japanとは

1枚目画像:Front View iPhone X and Macbook Pro Mockup

脚注
  1. https://blog.microcms.io/open-source-the-blog/ ↩︎

  2. https://v2.vuejs.org/lts/ ↩︎

  3. https://docs.netlify.com/git/overview/#git-repository-support
    個人開発だから個人垢に移せばええやんというツッコミには謝罪します、団体として活動はしてるけど仲間が集まらないんや... ↩︎

  4. 308 Permanent Redirect https://developer.mozilla.org/ja/docs/Web/HTTP/Status/308 ↩︎

  5. 認識違ったらごめんなさい。k6が実際にページ表示にリソース取得の数値も含めて約70000という数値を出しているのであれば文字通り200VUsの200PVぐらいしか捌けないです。。 ↩︎

Discussion