🐣

しずかなインターネットの技術構成

2023/11/29に公開
18

こんなWebサービスをリリースしたので、技術的な話をまとめておこうと思います。

https://sizu.me?ref=zenn

元々このサービスは、趣味の延長線のような感じで開発を始めました。競合にあたるnoteやはてなブログなどのサービスが確固たる地位を築いているということもあり、「お金にはならないだろうけど、自分の趣味を詰め込んだものにしよう」というゆるい気持ちで開発を続けています(楽しい)。

選定の方針

趣味と言っても文章投稿サービスなので、ユーザーが少数であったとしても長期間運営しなければなりません。そのため、ユーザー数が少なければランニングコストが数千円/月以下、ユーザー数が増えたときは段階的にコストが上がるように選定を行いました。

アプリケーション

フルスタックNext.jsアプリケーションをCloud Runにデプロイしています。各APIエンドポイントはNext.jsのAPI Routesで生やしています。

Next.jsについてはApp RouterではなくPages Routerを使用しています。途中でApp Routerへの切り替えを試みましたが、色々あって見送りました。

見送った理由

今は状況が変わっているかもしれませんが、以下のような理由でApp Routerの採用を見送りました。

  • Shallow Routesが存在しなかった(クエリ文字列を変えるだけでサーバーの処理が再実行されてしまった。これはpage.tsxからlayout.tsxにデータ取得処理を寄せても解決しなかった)
  • サーバー側でのデータの取得処理において、同一引数の関数の呼び出しをcache()でラップしても1度のリクエストの中で2度実行されてしまうことがあった(されないこともあって、そのあたりのデバッグが嫌だった。本番環境で「密かにDBへ同じクエリが複数回実行されてました」とかは本当に避けたかった。関連ツイート
  • Intercepting Routesを使いたかったが、ブラウザバック時のスクロール位置やちらつきなどの体験が微妙だった(結局Page Routerで自前でIntercepting Routes的なものを実装した)

断念PRのスクショ

データベース

PlanetScale

メインのDBにはPlanetScale(MySQL)を使っています。PlanetScaleは無料枠が大きく、今回のサービスであれば$29/月のプランで相当スケールするまでカバーできます。プランの利用枠を超えた分は従量課金となるため、値段が一気に跳ね上がる心配もありません。

PlanetScaleでは読み取り行数が料金に大きな影響を与えるため、効率的なクエリと適切なインデックスが重要になります。

PlanetScaleのダッシュボードのQuery Insights

嬉しいのはダッシュボードのQuery Insightsから各クエリのレイテンシや読み取り行数を確認することができるという点です(さらそのままEXPLAINを実行できる!)。この体験の良さがチューニングのモチベーションとなり、SQL素人の自分とって非常に良い勉強になっています。

難点

唯一の難点は、Cloud RunからPlanetScaleに接続する場合、Google CloudのDB(Cloud SQLなど)を使う場合に比べてパフォーマンスが悪くなるという点です。個人的な検証では、両リージョンを東京にした場合でも1度のクエリあたり10〜20msほど待ち時間が長くなりました。

今回はコストを最優先にすると決めていたので、これは仕方がないものとして諦め、できる限りDBからのデータの取得頻度を減らす / 他の非同期処理と同時に実行するようにしています。

ORM

Prismaを利用しています。

Upstash

一部の操作のレートリミットや一部のキャッシュはUpstashのRedisを利用しています。こちらもCloud RunからだとGoogle CloudのRedisサービスを使う場合と比べてどうしてもレイテンシが大きくなってしまってますが、仕方がないものとして許容しています。

CDN

CDNにはコスト面の理由からCloudflareを利用しています。CSSやJS、画像などの静的ファイルには長めにキャッシュを設定し、パージが必要になったらリソースのURLを変える(もしくは一括パージする)運用にしています。

Cloudflare Workers

Cloud Runにカスタムドメインを紐付けるとレイテンシが発生する問題の対策として、Cloudflare Workersにカスタムドメインを設定し、Cloud Runへのリクエスト(静的ファイルを除く)をプロキシするようにしています。

https://zenn.dev/catnose99/scraps/ffdd08cebfad12

Cloud Load Balancingを使うとよりレスポンスが早くなるのですが、ランニングコストとパフォーマンスのバランスを取ってこの形に落ち着きました。

静的ファイル用URLのpreconnect

静的ファイル用のURLにはsizu.meとは別のドメインを利用しています。これはCloudflareのプランの制約に加え、別ドメインから配信することで余計なCookie(特に認証に関するCookie)が静的ファイルへのリクエストに付与されるのを防ぐという目的もあります。

そのぶん静的ファイル用ドメインへのDNS Prefetchなどが別途必要になるため、<link rel="preconnect />の設置や 103 Early Hintsによるpreconnectの設定により、なるべく早くリクエストが開始されるようにしています。

https://zenn.dev/catnose99/scraps/1566bbc77f5bb9

オブジェクトストレージ

料金の安さからCloudflare R2を使っています。R2は現状バージョニングなどの機能に対応しておらず、うっかりファイルが削除されてしまったときに打つ手がなくなりそうなのが恐いところです。

そこで念のため、定期的にCloud Run Jobsからrcloneを実行し、R2上のファイルをCloud Storage(最安のarchiveクラスのもの)にバックアップしています。詳しくはこちらの記事で書きました。

https://zenn.dev/catnose99/articles/c0c710f98a0be8

決済

Stripeを利用しています。Checkout SessionのURLを発行し、ユーザーにStripeのサイト上で決済手続きをしてもらうようにしています。サービスのDBでは決済に関する情報はほぼ持たず、「サブスクリプションがアクティブかどうか」のみをキャッシュ的な目的で保持するようにしています(Webhookのハンドラで同期しています)。

ロギング・エラー通知

Cloud Runではconsole.logにより構造化ログを出力することができます。

https://zenn.dev/moga/articles/cloudrun-structured-log

エラーが発生したときには、ログレベル(severity)に応じて2種類の通知がSlackに飛ぶにように設定しました。

  • error: #prod-errorチャンネルにPOST。次回の作業時に確認する。
  • critical: #prod-criticalチャンネルにメンション付きでPOST。決済周りの不整合などごく一部のケースでのみ出力される。すぐに確認する。

通知の設定方法については以下の記事が参考になりました。

https://zenn.dev/team_zenn/articles/cloud-logging-log-alert

シークレットの管理

サーバーのランタイムから参照するシークレットはSecret Managerで管理しています。意外と月数百円かかっちゃってます。

非同期処理・バッチ処理

Cloud Runでは、メインサービスに加えて、Google Cloud内部からのリクエストのみを受け付けるタスク用のサービスをデプロイしています。

時間がかかる一部の処理はCloud Tasksを使ってタスク用サービスで非同期で処理するようにしています。バッチ処理はCloud Schedulerから定期的にタスク用サービスにHTTPリクエストを送る形で実行しています。

メール配信

料金と信頼性のバランスでAWS SESを選択しました。利用制限の緩和申請や、SPF/DKIM/DMARCの設定が手間でしたが、良い勉強になりました。本当はSend GridやResendを使いたかったのですが仕方なし。

ちなみにメールの配信処理はCloud Schedulerから呼び出しています。Cloud Schedulerはat-least-once(少なくとも1回の実行)という仕様であるため、重複実行されてしまう可能性も考えられます。同一のメールが2通送られてしまうのを避けるため、メールの送信前にRedisで排他制御をしています。

CI/CD

プルリクエストの作成時にはテストやLintingがGitHub Actionsで実行されるようにしています。mainブランチにマージすると、Cloud Buildが走り、Cloud Runに自動でデプロイされます。

IaC

Google Cloudの各サービスの設定についてはTerraformで管理しています。コードで管理すると安心感がありますね(Terraformの魅力はZennの開発チームにいたときにwaddy_uさんに教えてもらいました)。

認証

NextAuthとFirebase Authenticationを組み合わせて使っています。ただし、GoogleログインでFirebase Auth(signInWithRedirect)とNext Authを組み合わせると、ユーザーの待ち時間が5秒以上かかることがあり、体験が非常に悪かったのでGoogleログインについてはFirebase Authenticationを経由せずに使うようにしました。

これは個人的な憶測ですが、Firebase AuthのsignInWithRedirectの待ち時間が以前より長くなった(?)のは、サードパーティCookieが防がれた状態でFirebaseのドメイン間で認証を行うために裏側でゴニョゴニョやる必要が出てきたからではないかな?と予想しています(参考: サードパーティCookieをブロックするブラウザで signInWithRedirectを使用する場合

スタイリング

主にTailwindCSSを使っています。アニメーションなどが必要になったときには部分的にCSS Modulesを使っています。

データフェッチ/状態管理

クライアントからのAPIリクエストの部分にはtrpcを使っています。trpcを採用した理由はTypeScriptの型補完などの体験が良かったからです。trpcはreact-queryのラッパーとなるパッケージも提供しており、これを使うとデータの取得・更新処理をとても楽に書くことができます。

クライアントでの状態管理もできる限りreact-queryで済ませるようにしています。データのフェッチが絡まない一部のステートについてはjotaiを使っています。jotaiはバンドルサイズが小さくて良いですね。

画像のリサイズ

今後Next.jsから別フレームワークへ移行する可能性も考え、next/imageやISRなどのスペシャルな感じの機能はできるだけ使わないようにしました。画像のリサイズに関しては、APIに専用のエンドポイントを用意し、クエリ文字列で画像のURLや設定値を渡すと、リサイズされた画像を返すようにしました。

# イメージ
/api/resize-image/署名?url=元画像のURL&width=200

URLに含まれる署名はサーバーサイドでクエリ文字列から生成し、クエリ文字列が改竄されるとエラーを返すようにしています。

動的OG画像の生成

ユーザーごとのOG画像はsatoriを使って生成しています。こちらもURLに署名を含めるようにして、第三者が画像をいじることができないようにしています。

ちなみにXのsummary_large_image形式のリンクカードの表示が微妙になってしまったので、最終的にsummary形式向けの正方形画像の生成だけを行っています。

https://catnose.me/notes/use-summary

エディタ

記事編集用のエディタにはTipTapを使っています。詳細なドキュメントがあり、コードが読みやすく、拡張もしやすい(いざとなれば内部的に利用されているProseMirrorのAPIに直接アクセスできる)のでとても気に入っています。

テスト

バックエンドをメインに書いています(今数えたら書かれたテストケースは530個ありました)。ライブラリとしてはVitestReact Testing Libraryを使っています。DBをモックしたくなかったので、テスト用のDBを用意し、実行前にDBをリセットするようにしています。

テスト用データの挿入にはprisma-fabbricaというライブラリを使っています。prisma-fabbricaのおかげでテストコードがだいぶ簡潔になったと思います。


こんな感じで登場人物が多い技術構成になっています。コスト面を気にしなければもっと早くリリースできた & 表示速度をもっと早くできた & もう少し構成をシンプルにできたと思いますが、そこは仕方ありません。

技術の発展にゆるく乗っかり、少しずつ改良していきたいと思います。

Discussion

melodycluemelodyclue

ステージング環境って作っていますか?(Cloud Deploy?など...)

catnosecatnose

Google Cloudのプロジェクト自体を切り替える形でステージング環境を作っています。構成やデプロイ方法などは本番環境と同じです。

kyeshmzkyeshmz

なぜResendやSendgridを選択しなかったのか伺いたいです! Dedicated IPの料金とかでしょうか

catnosecatnose

料金です。Dedicated IPを除いてもAWS SESがコスト的にベストだと判断しました。

MCタツノボリMCタツノボリ

お答えいただける範囲で構わないのですが、ムードの音声はHTMLのaudioタグで再生とループさせてますでしょうか?

自分の手元で試しにやってみてるのですがaudioタグのループだとぶつ切り感があってしずかなインターネットのムードのように綺麗に繋がらないので、聞いてみた次第です!

AsaAsa

こちら、UIライブラリやスタイリングなどは何を選択しているでしょうか?

catnosecatnose

TailwindCSSを使っています!UIライブラリはポップオーバーにfloating-uiを使ってるくらいですかね。

AsaAsa

ご返信ありがとうございます!
似たような質問で申し訳ありませんが、Headless UIやコンポーネントライブラリなどは使用していない 
認識でよろしいでしょうか?

catnosecatnose

はい、使っていません。そのようなコンポーネントライブラリは個人開発では基本的に使わないですね。

AsaAsa

もし、可能であればコンポーネントライブラリを個人開発で使わない理由について
お聞かせいただきたいです。

catnosecatnose

https://mond.how/ja/topics/mwcwkx25fy471t8

以前こちらに書きました。UIライブラリに関わらず、自分で実装できるものはできる限り入れない方針です。バンドルサイズが必要以上に大きくなったり、アップデートに気を遣ったりするのがいやなので

AsaAsa

ありがとうございます!!とても参考になりました!
1月23日にあるcatnoseさんのイベント楽しみにしています!

つきみつきみ

質問失礼します!

記事での$29はおそらく、Scalerプランを指しているかと思いますが、現在ではこちらのプランは廃止されてしまい、$39〜のProプランのみとなってしまったのはご存知かと思います。
そこで、もし今後Scalerがプランが全面的に廃止されるとなった時、
ブランチや高速性、快適性などを考えて$39〜でもPayすると言ったことは考えていますか?
(もうすでにProプランに切り替えていましたらごめんなさい!)

また、公開できる範囲で構わないのですが、全体的にどれくらいのアクセス量をどれくらいの費用で賄っているなどもお聞きしたいです!

これからもしずかなインターネットを使っていきます!

catnosecatnose

今から他のクラウドサービスに移行するのはできるかぎり避けたいので、$39〜でも利用することになると思います。

どれくらいのアクセス量をどれくらいの費用で賄っているなど

現状だと100〜200万ページビュー/月ですが、Scalerプランでもまだかなり余裕があります。今後はストレージ部分の料金がネックになりそうですが、部分的にオブジェクトストレージに持たせること等も検討しています。