タグ
黒いカードに緑のUpstashの二重螺旋ロゴと白のロゴタイプ

Astroの静的ブログで「いいね」を数える — Vercelの1ルートとUpstash Redis

完全な静的Astroサイトにサーバーレスなルートをひとつだけ足し、「いいね」を共有カウントする方法。Upstash Redisのキー設計と、動作を確かめるまでに踏んだCIのバグをまとめます。

目次

はじめに

記事に「いいね」ボタンを付けたいと思っていました。最初に考えたのは、「いいね」もブックマークも結局は「これは価値があった」という意味なのだから1つのボタンで兼ねられるのではないか、そしてアカウントのないブログなら保存先はlocalStorageが自然だろう、ということでした。

エージェントはその両方に反対し、2つ目についての主張が役に立つものでした。読者のブラウザだけに保存された「いいね」は、誰にも何も伝えません。 読者にはすでにブックマークがあるので、新しく得るものはありません。私も何ひとつ分からないままで、それではボタンを付けたかった理由そのものが失われます。そこで、コストを払うほうを選びました。本物の共有カウンターです。つまり、サーバーを一度も持ったことのないサイトにサーバーを置くということでした。

本記事では、そのカウンターを作る価値を決めた設計上の理由、サーバーレスなルート1本が静的サイトに実際どれだけのコストを課すのか、そしてエンドポイントの動作を証明できるようになるまでに踏んだ3つのCIのバグを整理します。

集計値だけのカウントは根拠にならない

素直に作れば、エンドポイントは数値を1つ増やすだけです。その作りはcurlのループ1回で意味を失い、記事の下に出ている数値は何の根拠でもなくなります。

そこで、「いいね」1回につき1つではなく2つのレコードを書きます。

src/lib/likes.ts
const countKey = (slug: string) => `${KEY_PREFIX}likes:${slug}`;
const voteKey = (slug: string, voter: string) => `${KEY_PREFIX}likes:by:${slug}:${voter}`;

2つ目が、1つ目に意味を与えるものです。呼び出し元が有効な「いいね」を持っていることを記録し、キーにはデプロイの外に出ることのない秘密値と組み合わせたアドレスのSHA-256ハッシュを使い、30日で自動的に削除されます。

「いいね」は集計値と投票レコードを書き込み、投票レコードが集計値を根拠のあるものにする2つのキーを並べて示す。1つ目のlikes:<slug>は集計値で、TTLを持たない単なる整数である。2つ目のlikes:by:<slug>:<hash>は投票レコードで、呼び出し元アドレスのソルト付きSHA-256であり30日で失効する。その下に、2つ目のキーがもたらす3点を挙げる。再送しても数値は動かないこと、取り消しができること、aria-pressedが正しくなることである。これがなければ、ループ1回で数値は無意味になる。「いいね」1回が書き込むもの集計値likes:<slug>単なる整数。TTLなし。投票レコードlikes:by:<slug>:<hash>アドレスのソルト付きSHA-256。30日で失効。2つ目のキーが可能にすること再送しても数値は動かない取り消しができるaria-pressedが正しくなるこれがないとループ1回で数値は無意味になる
投票レコードこそが、集計値を単なる数値ではなく根拠にするものです。取り消しを可能にするのも同じレコードです。

TTLは少し考えました。永続では駄目です。家庭の回線は割り当てが変わるため、永続的なレコードはそのアドレスを次に引き継いだ人を黙って締め出します。24時間でも駄目です。同じ読者が1日1回ずつ永遠に「いいね」できてしまい、それは数えていないのと同じです。30日が折衷案で、その内容は暗黙のままにせずサイトのプライバシーページに書いてあります。

投票レコードは、目的ではなかったものをもう1つもたらします。サーバーが「いいね」の有効・無効を知っているため、取り消せるのです。

src/lib/likes.ts
if (liked) {
// `nx` is what makes this idempotent: it writes only if no vote exists,
// and returns null when one already did.
const claimed = await store.set(vote, 1, { nx: true, ex: VOTE_TTL_SECONDS });
if (claimed === null) return { count: await readCount(slug), liked: true };
return { count: await store.incr(countKey(slug)), liked: true };
}

このnxは実際に働いています。2つのタブ、ダブルタップ、応答が落ちたあとの再送は、いずれもclaimed === nullの分岐に入り、集計値を動かさずに本当の状態を返します。そして取り消しがあるからこそ、ボタンは一方通行の操作にトグルの見た目を被せるのではなく、aria-pressedを正しく名乗れます。

サーバーレスなルート1本が静的サイトに課すもの

Astroはoutput: 'static'のままです。すべてのページは変わらず事前生成され、アダプターはファイル1つだけがそこから抜けられるようにするために入れてあります。

src/pages/api/likes/[slug].ts
export const prerender = false;

slugはgetCollection('blog')から作った許可リストと照合されるため、呼び出し元が勝手なキーを作ってデータベースを埋めることはできません。読み取りはCDNで1分間キャッシュされ、よくあるケースは関数にまったく届きません。

キャッシュされた読み取りはCDNで止まり、「いいね」だけが関数とRedisに届く読者、Vercel CDN、関数、Upstash Redisという4段階を左から右へ並べた図である。関数とデータベースはいずれも東京、hnd1とap-northeast-1にあると示される。s-maxage=60と記されたキャッシュされた読み取りはCDNで止まり、関数には届かない。no-storeと記された「いいね」は最後まで進み、Redisに到達して戻る。リクエストが実際に届く範囲読者Vercel CDN関数hnd1・東京Upstash Redisap-northeast-1キャッシュされた読み取りここで止まるs-maxage=60「いいね」最後まで届くno-store
クリックしない読者は関数に届きません。最後まで進むのは「いいね」か、キャッシュミスのときだけです。

ルート自体は安いほうでした。@astrojs/vercelを入れるとビルド出力がdist/から.vercel/output/static/へ移り、既存の2つの手順に古いパスが埋め込まれたまま残りました。この失敗は、いちばん質の悪い形で無言です。 pagefind --site <missing>は空のインデックスを書いて0で終了するので、ビルドは緑のまま、何も見つからない検索ボックスを載せたサイトが出ていきます。

package.json
"build": "astro build && pagefind --site .vercel/output/static && pnpm run pagefind:patch",
"preview": "pnpm dlx serve .vercel/output/static",

previewの行が2つ目のコストです。astro previewはアダプターがあるとそもそも動かないため、スクリプトはビルド済みディレクトリを直接配信するようになりました。Pagefindのあとに走る日本語検索のパッチも参照先を変え、パスが違えばENOENTだけを出すのではなく明示的に失敗するよう存在チェックを足しました。

Upstashの用意と、ダイアログで決める3つのこと

Upstash for RedisはVercel Marketplaceから入れます。別途セットアップするのではないので、認証情報はどこかに貼り付けるのではなくプロジェクトに注入されます。

UpstashのVercel Marketplace製品一覧。4枚のカードが並び、Upstash for Redisが赤枠で囲まれ、ほかにUpstash Vector、Upstash QStash/Workflow、Upstash Searchがある。各カードにInstallボタンと料金プランが付いている。

4つの製品が1つの連携を共有しています。今回必要なのはRedisだけです。

接続ダイアログは3つのことを尋ねます。そのどれもが、押す前に知っておく価値のある結果を伴います。

VercelのInstall Integrationパネル。EnvironmentsではProductionとPreviewにチェックが入り、Developmentは外れている。Custom Prefixの欄にはSTORAGEというプレースホルダーと_URLの接尾辞が見える。Sensitiveのトグルはオンになっている。

環境、プレフィックス、そしてSensitiveのトグル。3つ目だけは元に戻せません。

Environmentsは、どのデプロイに認証情報を渡すかを決めます。ProductionとPreviewにチェックを入れ、Developmentは意図的に外しています。認証情報がなければエンドポイントはメモリ上のカウンターにフォールバックして警告を出すので、pnpm devにシークレットは一切不要です。

Custom Prefixは、注入される変数名を書き換えます。コード側が変数名を明示している場合は空のままが正解で、プレフィックスを付ければ本番でだけ503として現れる不一致を確実に作り込むことになります。

Sensitiveは、値を読み出せない形で保存します。本番の衛生としては正しく、そして結果が伴います。あとからダッシュボードでもAPIでもvercel env pullでも値を読み戻せません。後で値が必要になったものは、代わりにローテーションが必要になります。

Vercel上に用意されたUpstashのデータベース。StatusはAvailable、PlanはFree。.env.localタブのQuickstartパネルに5つの変数が並ぶ。KV_REST_API_READ_ONLY_TOKEN、KV_REST_API_TOKEN、KV_REST_API_URL、KV_URL、REDIS_URLで、値はすべて伏せられている。

5つの変数がプロジェクトに入ります。コードが使うのはそのうち2つです。

使うのはKV_REST_API_URLKV_REST_API_TOKENだけです。KV_URLREDIS_URLは従来型のクライアント向けのTCP接続文字列で、サーバーレス関数はTCPのコネクションプールを保持できないため、ここではREST経由でRedisと話します。

無料枠は月間500,000コマンドと256 MB、VercelのHobbyプランは関数の呼び出しを月100万回まで許します。個人ブログではどちらも近づきません。

リージョンは2つではなく1つの決定

Vercelの関数は既定でワシントンのiad1に置かれ、Hobbyプランではリージョンは1つだけです。Upstashは作成時にプライマリリージョンを尋ね、その選択はあとから変更できません

放っておくと、この2つの既定値は考えうる最悪の配置を作ります。日本の読者の「いいね」が太平洋を渡って関数に届き、そこからまた戻って東京のデータベースに届くのです。このサイトの3つのロケールのうち2つが日本語と繁体字中国語なので、私は両方を東京にしました。

vercel.json
{ "regions": ["hnd1"] }

Vercelのリージョン名はAWSに合わせてあるので、hnd1とUpstashのap-northeast-1は同じデータセンターです。

1つのデータベース、2つのキー空間

この連携はデータベースをProductionにもPreviewにも同じようにつなぎます。つまり、ブランチの動作確認中に押した「いいね」が、公開済みの記事の下の数値を動かしてしまうということです。

1つのRedisデータベースが2つのキー空間を持ち、プレビューの「いいね」は公開中の集計値を動かせない1つのUpstash Redisデータベースが2つの環境に接続されている図である。VERCEL_ENVがproductionである本番の列は、プレフィックスのないキーlikes:<slug>を使い、後の移行を必要としない。VERCEL_ENVがpreviewであるプレビューの列はpreview:likes:<slug>を使い、ブランチからの書き込みはすべてそこに入る。下部には分離を証明する手順として、プレビューで「いいね」したうえで本番の数値を読み、動いていないことを確認すると示される。1つのデータベースが両方の環境に接続されているUpstash Redis本番VERCEL_ENV=productionlikes:<slug>プレフィックスなし。後の移行も不要。プレビューVERCEL_ENV=previewpreview:likes:<slug>ブランチからの書き込みはすべてここに入る。それを証明する確認手順プレビューで「いいね」し、本番の数値を読む。数値は動いてはならない。
1つのデータベースに2つのキー空間。本番のキーはそのままの名前を保つので、これを将来やめても移行するものは何もありません。

修正は3行で、Vercelがすべてのデプロイに設定する変数を使います。

src/lib/likes.ts
const KEY_PREFIX = VERCEL_ENV && VERCEL_ENV !== 'production' ? `${VERCEL_ENV}:` : '';

これが効いていることの証明に、データベースへのアクセスは要りません。プレビューのURLで記事に「いいね」し、そのあと本番のカウントを読んで動いていないことを確かめるだけです。2つのエンドポイントだけを使うブラックボックスの確認で、実装しているキーではなく実際に大事なほうを試しています。

221行をバイナリにしたNULバイト

マージ前のコードレビューが、型チェッカーには決して見つけられないものを見つけました。

src/lib/likes.ts | Bin 0 -> 8189 bytes

エージェントがハッシュの入力を組み立てるテンプレートにリテラルのU+0000を書き込み、そのあとファイルを2回読み返しても気づいていませんでした。これは空白として表示されます。gitのバイナリ判定はまさにそのバイトを見るので、このサイト唯一のサーバーサイドのモジュールは、プルリクエストに「Binary file not shown」として並ぶところでした。 git diffにもgit blameにもgrepにも、以後ずっと見えません。

最後のものが静かな部分です。ファイルがそこにあって中身に含んでいるのに、grep -rn "LikeState" src/は何も返しませんでした。修正は挙動が同じで、幅は1文字です。

src/lib/likes.ts
return createHash('sha256').update(`${address}\0${salt}`).digest('base64url').slice(0, 32);

そのあとgit diff --statBinではなく221 +++++と報告しました。

スモークテストが通るまでに踏んだ3つのバグ

エンドポイントを書くのに半日、動くと証明するのにプルリクエスト4本かかりました。

差の理由は、pnpm checkpnpm buildもこのルートを一度も呼ばないことです。コンパイルとバンドルができることは証明しますが、デプロイされた関数に何かが当たるまで2つが未証明のまま残ります。Vercelの関数の中で動くgetCollection('blog')と、Upstashのクライアントが実際にRedisへ届くことです。私はプレビューのデプロイを開かないままこの機能をマージしたので、両方の最初の実行は公開中のサイト上でした。動きはしましたが、それは証拠ではなく運です。

そこで次の変更は、デプロイのたびにエンドポイントをスモークテストするワークフローでした。これが3回失敗しました。

1つ目のバグはマッチャーでした。 公開済みの記事を^status: *publishedで探していましたが、フロントマターの実際の表記は引用符を含むstatus: 'published'です。何にも一致せず、ループを抜け、こう報告しました。

No published article found to test against.

このメッセージが述べているのはコンテンツの不在です。実際の問題は壊れたパターンで、間違ったメッセージは間違った結果よりも高くつきます。

2つ目はトリガーでした。 deployment_statusはリポジトリ上のすべてのGitHub Deploymentで発火し、ここには2種類あります。Vercelのものと、既存のCIジョブにあるenvironment: productionのブロックが作るものです。後者ではtarget_urlはActionsのジョブページです。ワークフローは律儀にgithub.comへcurlしました。

作成者 target_url
vercel[bot] そのデプロイのサイトURL
environment: productionのブロック https://github.com/…/actions/runs/…/job/…

今はURLの形ではなく作成者で判定しているので、あとからカスタムドメインを足しても黙って無効化されることはありません。

3つ目は認証でした。 vercel deployは本番のエイリアスではなくデプロイ固有のURLを出力し、デプロイ固有のURLはDeployment Protectionの対象です。リクエストはログインページへのリダイレクトとして返り、ステップはその本文をカウントとして解釈しようとしました。

Unexpected body from the like endpoint: Redirecting...

修正はProtection Bypass for Automationのシークレットで、x-vercel-protection-bypassヘッダーとして送ります。そもそもエイリアスではなくデプロイURLを試すほうが良い確認でもありました。エイリアスの切り替わりを待たずに、今ビルドされた成果物そのものに当たるからです。

共通する筋は書いておく価値があります。GitHubはdeployment_statusをデフォルトブランチにあるワークフローファイルにしか配信しません。 つまりこのワークフローは、マージされるまで走れませんでした。3つのバグはどれも、見つかるためにいったん出ていく必要があったわけです。4周目を止めたのは、修正をマージする前にバイパスのシークレットを使って各チェックを手で走らせたことでした。

通ったあと、実行ログの5行がそれまでの節の主張をそのまま運んでいました。

GET /api/likes/a2a-mcp-... -> {"count":0}
unknown slug -> 404
POST like -> {"count":1,"liked":true}
POST unlike -> {"count":0,"liked":false}
production before={"count":0} after={"count":0}

最後の行がキー空間の分離の証明で、これが今後のすべてのプルリクエストで自動的に走ります。

図版チェックが、応答しているサーバーを疑った

4つ目の間違ったメッセージが、この記事を書いている最中に出てきました。「いいね」カウンターが触れた範囲ではなく、このブログ自身のツールの中です。pnpm figures:fitはすべての図版を描画し、ラベルをその枠に対して測ります。これが、単一記事の実行は通るのに全体の走査だけ失敗するようになりました。

Could not reach http://localhost:4321. Start the dev server first: pnpm dev

開発サーバーは動いていて、その間ずっと同じポートでcurlに応答していました。診断はメッセージではなくタイミングにありました。 1ページ目は639 msで落ち着き、2ページ目はリクエストが1つも飛んでいない状態でちょうど30,000 msでタイムアウトしたのです。このチェックはPlaywrightのwaitUntil: 'networkidle'で遷移していて、ViteはHMRのWebSocketを開いたまま保持するため、それが待っているコネクション数ゼロの状態は確実には訪れません。遷移を囲むcatchが、あらゆる失敗をサーバーの停止として報告していました。

waitUntil: 'load'に変えると直り、走査は今では60ページを3秒ほどで測ります。残しておく価値があるのは、その次に危うかったことです。networkidleは、書かれていない2つ目の仕事もしていました。 図版のラベル28か所が測られる、自前配信の等幅Webフォントの到着を待つことです。明示的なdocument.fonts.readyを足さずに外しても、実行は壊れなかったでしょう。測定結果が黙って変わっただけで、そちらのほうが高くつく失敗です。

まとめ

  • 読者のブラウザだけに保存された「いいね」はシグナルではありません。 読者にはすでに持っているもの以上を与えず、書き手には何も与えません。これが本物のカウンターのコストを払う理由です。
  • 加算しかしないエンドポイントはカウントではありません。 投票レコードが数値を根拠にし、同時に取り消しを可能にします。それがボタンにaria-pressedを正しく名乗らせます。
  • サーバーレスなルート1本のコストは、ルートだけではありません。 アダプターはビルド出力を移し、pagefind --site <missing>は空のインデックスを書いて0で終了します。
  • 関数のリージョンとデータベースのリージョンは1つの決定です。 Upstashのプライマリリージョンは作成後に変更できず、Vercelの既定値は別の大陸にあります。
  • ソースファイル中のU+0000はgitにそれをバイナリとして扱わせ、エディタでは完全に正常に見えたままdiffblamegrepから隠します。
  • 出荷しないと試せない作業は、壊れたまま出荷されます。 deployment_statusはデフォルトブランチからしか配信されないので、どの修正もマージされるまで確認できませんでした。
  • 原因を取り違えるチェックは、素直に失敗するチェックより高くつきます。 ここでは2回、実際の原因が何にも一致しないパターンとタイムアウトした遷移だったのに、メッセージはコンテンツの不在やサーバーの停止を述べていました。

参考リンク

この記事をシェア