Goでアーキテクチャ違反とデッドコードを機械的に検出する

AIエージェント × knipで無駄コードを簡単に掃除等で紹介されているが、 TypeScriptであればknipのような便利ツールがある。Goだとdeadcodeがそれかなーと思いつつ、AI時代にデッドコードを削除したり、アーキテクチャの制約を機械的に検知したくなるのでメモ書き。

deadcodeでコード削除

皆さんお馴染みdeadcode は Go公式ツールの一部。コールグラフ解析で到達不可能な関数を検出する。

go run golang.org/x/tools/cmd/deadcode@latest -test ./...

-test つけるとテストから呼ばれてる関数は除外してくれる。

go-arch-lintでアーキテクチャを保つ

go-arch-lint は Go のパッケージ間依存を YAML で定義して違反を検出するツール。

存在はこれで知った。

developers.cyberagent.co.jp

.go-arch-lint.yml をリポジトリルートに置く:

(例)

version: 3
workdir: .

components:
  # Domain 層
  domain_user:
    in: internal/domain/user
  domain_order:
    in: internal/domain/order

  # UseCase 層
  usecase:
    in: internal/usecase/**

  # Infrastructure 層
  infra_repository:
    in: internal/infra/repository
  infra_repository_impl:
    in: internal/infra/repository/postgres
  infra_external:
    in: internal/infra/payment

  # 共通パッケージ
  pkg:
    in: pkg/**

commonComponents:
  - pkg

deps:
  domain_order:
    mayDependOn:
      - domain_user

  usecase:
    mayDependOn:
      - domain_user
      - domain_order
      - infra_repository
      - infra_external

  infra_repository_impl:
    mayDependOn:
      - domain_user
      - domain_order
      - infra_repository

mayDependOn はホワイトリスト方式。なので書いてないやつは全部違反になる。

golangci-lintで未使用コード検出

unused(未使用の宣言)、unparam(未使用の引数)、ineffassignwastedassign あたりを有効化し、いらないものを検知できるようにする。

linters:
  enable:
    - unused
    - unparam
    - ineffassign
    - wastedassign

カスタムリンター

go-arch-lint で足りなければ go/ast でカスタムリンター書ける。例えば UseCase の公開メソッドにGoDocを強制するリンター

file, _ := parser.ParseFile(fset, path, nil, parser.ParseComments)

for _, decl := range file.Decls {
    fn, ok := decl.(*ast.FuncDecl)
    if !ok || fn.Recv == nil || !ast.IsExported(fn.Name.Name) {
        continue
    }

    // レシーバの型名を取得(*OrderUseCase → "OrderUseCase")
    recvType := fn.Recv.List[0].Type
    if star, ok := recvType.(*ast.StarExpr); ok {
        recvType = star.X
    }
    ident, ok := recvType.(*ast.Ident)
    if !ok || !strings.HasSuffix(ident.Name, "UseCase") {
        continue
    }

    // GoDoc に必要な行があるかチェック
    if fn.Doc == nil {
        // GoDoc 自体がない → 違反
    } else {
        found := false
        for _, c := range fn.Doc.List {
            if strings.Contains(c.Text, "....") {
                found = true
            }
        }
        if !found {
            // ... 行がない → 違反
        }
    }
}

parser.ParseFile して file.Decls をループ、条件に合う関数の fn.Doc を覗くだけ。100〜200行で書けるのでプロジェクト固有ルールの強制におすすめ。

試してないやつ

DatadogのHow we reduced the size of our Agent Go binaries by up to 77%が面白かった。 Datadogはdeadcodeでリンカーの最適化が無効化を特定し、reflect の呼び出しサイトをパッチ。さらに goda で依存グラフを可視化して不要なパッケージ依存を丸ごと削除。結果、バイナリサイズ最大77%削減したらしい。

自分のプロジェクトはそこまで巨大じゃないのでソースコードレベルで十分だけど、大規模になったらこのアプローチも試してみたいなーと思う。

Next.js on Cloudflare Workers からCloud Run Worker Pool(cloudflared)へのセキュアな接続を考える

2023年くらいからの Next.js(App Router)の流行りもあり、昨今当たり前のように使われるようになった RSC。ユースケースによりますが、セキュリティや可用性向上のため、RSC は BFF のレイヤーとしてAPI サーバーは別に分離する構成を取るのは割と多いんじゃないでしょうか。

Next.js v16 では Vercel Data Cache への依存が強まり、ある程度ちゃんとNext.jsの機能を使うならVercel に乗るか OpenNext に頼るかの二択になりつつあります。Cloudflare Workers にデプロイする場合は OpenNext for Cloudflare を使うことになりますが、ここで問題になるのが Workers 上の RSC からバックエンドの Cloud Run にどうやってセキュアにアクセスするか です。

Cloud Run はInternal Onlyに設定してパブリック URL からのアクセスを遮断したい。でも Workers はあくまで Cloudflare のエッジで動いているので、GCP の VPC 内部からのアクセスにはなりません。外部 LB を立てれば簡単ですが、もう少しセキュアにできないか探ってみました。(アプリレイヤーで認証入れるとかではなく)

よくある「GCE に cloudflared を入れて Tunnel 経由で繋ぐ」パターンは、GCE の面倒を見る必要があるので今回は省きます。代わりに Cloud Run Worker Pool(BETA)で cloudflared を動かし、Cloudflare Tunnel 経由でアクセスする構成を試しています。

この記事では、Workers → Cloud Run のアクセスパターンをいくつか検討してみます。

全体像

先に構成の全体像を見せます。

ブラウザからの Client Component のリクエストも、Server Component のリクエストも、どちらも Cloudflare Workers 上で Service Token を付与してバックエンドに送ります。

Workers → Cloud Run アクセスパターン

Workers から INTERNAL_ONLY の Cloud Run にアクセスする方法をいくつか検討しました。

1: Workers VPC + Tunnel

Cloudflare Workers VPC を使えば、Workers から直接 VPC 経由で cloudflared に接続できるのでは?と考えました。

しかし Workers VPC は Tunnel 接続に QUIC(UDP)プロトコルを必須 としています。

Workers VPC also requires Cloudflare Tunnel to connect using the QUIC transport protocol using auto or quic. Ensure outbound UDP traffic on port 7844 is allowed through your firewall for QUIC connections. — Workers VPC - Tunnel Configuration

Cloud Run Worker Pool では UDP のアウトバウンドに制約があり、cloudflared を QUIC で動かすことができません。実際、cloudflared のデフォルト(QUIC / auto)だと no recent network activity エラーが出るため、明示的に --protocol http2 を指定しています。

つまり Workers VPC は QUIC 必須なのにCloud Run Worker Pool は HTTP/2 しか通せない。これでは繋がりません。

2: mTLS(Managed Client Certificate)

次に考えたのが、Cloudflare の Managed Client Certificateを使った mTLS です。Workers の mTLS バインディングでクライアント証明書を送り、バックエンドを認証する方式です。

# wrangler.toml
[[mtls_certificates]]
binding = "API_CLIENT_CERT"
certificate_id = "xxxxxxxx-xxxx-..."
// Workers から mTLS 付き fetch
const res = await fetch("https://api.example.com/endpoint", {
  cf: { clientCertificate: env.API_CLIENT_CERT },
});

しかしこれも使えませんでした。

Currently, mTLS for Workers cannot be used for requests made to a service that is a proxied zone on Cloudflare. If your Worker presents a client certificate to a service proxied by Cloudflare, Cloudflare will return a 520 error. — mTLS - Cloudflare Workers

Tunnel 経由のホスト名は DNS レコードが proxied = trueなので、Cloudflare Edge で SSL が終端されます。Workers → Cloudflare Edge 間でクライアント証明書を送っても、Edge で終端されてしまうので Tunnel の先にある Cloud Run には届きません。

3: Zero Trust Service Token

結局、一番シンプルな方法に落ち着きました。Cloudflare Access のService Token使ってリクエストを認証する方式です。ただ自前でローテだったりしないといけないので微妙だなーと思っています。

Service Token はマシン間通信用の認証トークンで、Dashboard から発行すると Client IDClient Secret のペアが手に入ります。HTTP ヘッダーに付与するだけで認証できます。

  • CF-Access-Client-Id: <Client ID>
  • CF-Access-Client-Secret: <Client Secret>

Workers の Secretsにトークンを登録しておけば、コード内で自由に使えます。

  • Server Component → server-onlyを使って直にAPIサーバーを叩く
  • Client Component → /api/proxy API Route 経由でAPIサーバーにアクセス

どちらのケースでもWorkersがAPIサーバーのクライアントとなるようにします。

実装

Client Component 用のプロキシ

ProxyではRFC9110該当ヘッダー等を削除しないといけませんが、HonoのProxy Helperを使うと良いかなと思います。

hono.dev

import { Hono } from "hono";
import { proxy } from "hono/proxy";
import { handle } from "hono/vercel";

const app = new Hono().basePath("/api/proxy");

app.all("/*", async (c) => {
  // ....

  const res = await proxy(targetUrl, {
    ...c.req,
    headers: {
      ...forwardHeaders,
      ...cfAccessHeaders, // Service Token
      host: undefined,
    },
  });

 // ...
  return res;
});

const handler = handle(app);
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const DELETE = handler;
export const PATCH = handler;

Cloud Run Worker Pool で cloudflared を動かす

Tunnel のコネクタである cloudflared を常駐させるのに Cloud Run Worker Pool(BETA)を使っています。

Worker Pool は HTTP リクエストを受けずに常駐するプロセスを動かすためのリソースです。cloudflared は Cloudflare Edge に対して常時 HTTP/2 接続を維持するデーモンなので、リクエスト駆動型の Service だとインスタンスが落ちてしまいます。Worker Pool なら MANUAL scaling で 1 インスタンスを固定起動できます。

cloudflared → Cloud Run API 間は Private Google Access で内部ルーティングしています。Private DNS Zone で *.a.run.appprivate.googleapis.com の VIP に向けることで、パブリックインターネットを経由しません。

↓と同じです。参考になります。

Cloudflare Tunnel で内部公開の Cloud Run にアクセスしてみる | CyberAgent Developers Blog

Cloud Run Serviceの負荷分散

cloudflared から Private Google Access 経由で Cloud Run API にアクセスする場合、Private DNS Zone で解決される 199.36.153.8/30 は Google Front Endの Anycastアドレスです。DNS ラウンドロビンではなく、BGP で最寄りの Google フロントエンドに到達し、そこから Cloud Run のオートスケール中のインスタンスに分散します。

つまり、Private Google Access 経由でも Cloud Run のオートスケール+ビルトイン LB はそのまま効きます。Public URL 経由と負荷分散の動作に差はありません。

cloudflared Worker Poolの負荷分散

Worker Pool は MANUAL scaling のみで、ビルトインのオートスケールはありません。HTTP エンドポイントを持たないのでリクエストベースのスケールトリガーがそもそも存在しないためです。

manual_instance_count を増やせば複数インスタンスを起動できます。cloudflared は同じ Tunnel Token で最大 25 レプリカまで動かせます。

By design, replicas do not offer any level of traffic steering (random, hash, or round-robin). — Tunnel availability and failover

Cloudflare は地理的に近いレプリカにリクエストをルーティングし、1 台が落ちたら別のレプリカにフェイルオーバーします。Cloud Run Worker Pool は同一リージョンなので、実質どれかに振られるようになります。(多分) 実運用ではオートスケールが必要なワークロードの場合は CREMA(Cloud Run External Metrics Autoscaler) を使って外部メトリクスベースでスケールさせることもできるぽいです。

まとめ

ここまで書きましたが、結局Service Tokenなら経路とか可用性考えても外部LBで良いかなと思いました。

Claude Code御一行様用 個人的設定

この記事は99%手書きです。

自分はClaude(Opus)を基本は使用しつつ、レビューにCodex(5.3)とGitHub Copilotを混ぜて使ってます。よくあるやつです。

最近はユーザー側であれこれ設定しなくてもそれなりに良い感じに動きます。とはいえ設定した方が効率よく進むのも事実なので、最低限の整備はしておいて損はありません。新しい盆栽感がありますね。

汎用的なやつ

汎用的なPlugin、例えばコミットルールだとかは、Anthropicの公式Plugineverything-claude-code で基本十分だと思っています。ただサードパーティ製のものはネットワーク周り気をつけましょう。アウトバウンドはよほど信頼できるドメイン以外はユーザーの承認を得る様にした方が良いとは思います。

あとは↓を参考にしていけばとりあえず使う分にはまあ十分な気はします。

code.claude.com

とはいえ色々やっていくとチームとしてのルールや個人としてのこだわりを別途定義していくことになるとは思うので、その辺を簡単に紹介します。

とにかく編集するファイルを少なくする

自分はこの方針でやってます。特段特別なことはしていません。

  • 各Agentがはじめに読み込むファイル(Claude.mdとかAGENTS.mdとか)は短く保つ、基本は絶対に読んでほしいdocsへの参照のみにする
  • 各ベンダーのAgentが使うファイルはシンボリックリンクでまとめる
  • プロジェクト固有のdocsは1つのfolderに集約し、SSoTとし重複するドキュメントを残さない
  • ユースケースに応じてskillsを定義しdocsを参照させる(基本は各skills内で絶対に読んでほしいdocsへの参照のみ)

一応Claudeの公式ドキュメントにもCLAUDE.md 短く保て~ドメイン知識はskillsを~みたいなことは書いているので、方針としては間違ってはないんじゃないかなとは思います。

CLAUDE.md はすべてのセッションで読み込まれるため、広く適用されるもののみを含めます。ドメイン知識またはときどきのみ関連するワークフローについては、代わりに skills を使用します。Claude はオンデマンドで読み込み、すべての会話を膨らませません。

簡潔に保ちます。各行について、次のように尋ねます。「これを削除すると Claude が間違いを犯しますか?」 そうでない場合は、削除します。膨らんだ CLAUDE.md ファイルは Claude があなたの実際の指示を無視する原因になります。

kwsk

  • docs フォルダをSSoTとする

    • すべての設計ドキュメント、ガイドライン、仕様はここに集約してます。一応人間が読めるものにしておきたいので階層化しています。
  • Agents.md.agents フォルダをskillsの実体の置き場にする

    • claude.md.claude/ .codex/ はそれぞれシンボリックリンクとして参照させます。これが詳しいです。
  • skills / Agents.md / copilot instructionは参照ルールのみを記載する

    • 中身は概要とdocs/ 内のファイルへのリンクのみを記載しシンプルに保ちます。コンテンツの実体は常に docs/ に置くという原則を徹底します。
    • ただ必ずしもdocsを全部読んでくれないこともあるのでベタ書きした方が精度?は上がるような気がしています。気がしているだけかもしれません。
  • Agents.md` は行動の指針のみを書く

    • 前項通り具体的な技術仕様やドメイン知識は docs/ に委ね、Agents.md にはエージェントがどう振る舞うべきかの方針だけを記載します。
  • docsを常に最新に保つskillsを定義する

    • ドキュメントが陳腐化しないよう、更新を促すskillsを用意しておきます。
  • Git Hooksでコミット時に最低限の静的解析を走らせる

    • 自分はlefthookを使っていますが、コミット時に最低限の品質チェックが動くようにしておきます。TypeScriptであればtsceslintknipを回しておく、といった感じです。ルールベースの仕組みは品質担保のために最低限必要です。ただ勝手にバイパスしやがることがあるのでどうにかならんのでしょうか。
  • 譲れない個人の思想を載せる

    • あとは自分の設計思想を載せれば完成です。最終的にレビューするのは自分なので自分の思想にあった実装にさせます。

      • TypeScriptでアプリケーションを構成する場合、コンパニオンオブジェクトベースの関数型Likeな設計にしつつ、ドメイン集約における~~
      • TerraformのState管理はライフサイクル単位でstateを分離し、機能凝縮を意識したmodule設計に~
    • こうした思想 docs/ に書き、review用のskillsから参照させます。

Pluginの管理

基本は Claude Code Skillの配布方法 を参考に、個人のGitHubリポジトリでPluginを管理するのが良いと思います。チームで運用する場合は、チーム専用のClaude Code Pluginマーケットプレイスのようなアプローチも参考になります。

MCP

基本常時使っているMCPはContex7くらいです。

github.com

あとは必要になったら使います。

参考

github.com

zenn.dev

zenn.dev

zenn.dev

zenn.dev

zenn.dev

nyosegawa.github.io

zenn.dev

blog.cybozu.io

まとめ

面倒だった考えてあとはやるだけの部分をやってくれるようになり、良い時代になったなあと思います。残る課題は自然言語に直す手間くらいでしょうか。生体電気から直接考えていることを吸い取ってくれるのはいつなのでしょうか。

Gemini Live API + DeepgramでリアルタイムAI音声会話をする

GeminiのLive API は音声をそのまま受け取って会話できて便利なんですが、日本語で使っているとinputTranscription が長時間発話で不安定になる問題がありました。

ユーザー側のターンで発話での応答が遅延・欠落してしまう問題が。。。

inputTranscriptionを結合してみてみるとなんか、発話時間?が一定を超えたらめちゃくちゃバグってる。

抜粋 生産、生産、生産、生産、生産、生産、生産、SEEM、SEEM、SEEM、SEEM、SEEM、SE、SE、SE、SE、SE、SE、SEEM、SEEM、SEEM、SEEM、SEEM、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、ふ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、あ、そう、そう、そう、そう、そう、そう、そう、そう、そう、そう、そう、そう、そう、うん

zenn.dev

そこで音声認識(STT)を Deepgram に分離し、Live API にはテキストだけを渡す構成にして安定化を図ることにしました。

マイク → AudioWorklet (16kHz PCM) → Deepgram STT (WebSocket)
                                          ↓ テキスト
                                    Gemini Live API (テキスト入力のみ)
                                          ↓ 音声応答 (24kHz PCM)
                                    Web Audio API → スピーカー

音声は Deepgram にだけ流し、Live API には sendClientContent() でテキストだけを送ります。よくあるSTT→LLM→TTSのLLM→TTSの部分が一体になってるイメージです。

技術 役割
STT Deepgram nova-3 日本語音声 → テキスト変換
AI会話 + TTS Gemini Live API テキスト入力 → 音声応答生成
音声処理 Web Audio API + AudioWorklet 入出力の PCM 変換・再生

本当はSTTはVADとかASRとか色々考えないといけないんですが、いい感じに抽象化して一般消費者にも扱いやすいお値段で提供してくれているので時代に感謝ですね(?)

参考 engineers.ntt.com

15分のセッション1回あたりの概算はこんな感じです。

サービス 単価 15分あたり
Deepgram nova-3 (streaming, multilingual) $0.0092/min 約 $0.14
Gemini 2.5 Flash Native Audio (audio output) $12.00/1M tokens32 tokens/sec 約 $0.17〜$0.35 ※
合計 約 $0.31〜$0.49/回

※ Gemini の音声出力コストは AI の発話時間に依存します。15分のセッション中 AI が半分話す想定で約 $0.17、ほぼずっと話す想定で約 $0.35 です(32 tokens/sec × 60sec × 分数 × $12.00/1M tokens)。テキスト入力分($0.50/1M tokens)は少量のため省略。


Deepgram で日本語 STT

ブラウザからの接続にはサーバー側で一時トークン(TTL 1200秒 = セッション15分 + バッファ5分)を発行しています。クライアント側の接続設定はこんな感じです。

const connection = deepgram.listen.live({
  model: "nova-3",
  language: "ja",
  smart_format: true,
  punctuate: true,
  interim_results: true,
  endpointing: 1500,       // 1.5秒の無音でspeechFinal発火
  utterance_end_ms: 2000,   // フォールバック(speechFinal未発火時)
  vad_events: true,
  encoding: "linear16",
  sample_rate: 16000,
  channels: 1,
});

endpointing は当初2500msでしたが、応答までの体感が遅かったので1500msに短縮しました。utterance_end_msendpointing より長くしないと二重送信になるので2000msにしています。

Deepgram のイベントで大事なのは isFinalspeechFinal の区別です。isFinal は「このチャンクのテキストが確定した」で、1回の発話中に何度も来ます。speechFinal は「発話が終わった」で、最後に1回だけ来ます。

この speechFinal をトリガーにして Live API に turnComplete: true を送り、AIの応答を開始させています。さらに isFinal の時点でテキストを turnComplete: false で先行送信しておくと、Gemini が事前にコンテキストを処理し始めるので、speechFinal 後の応答が体感で速くなります。(多分)


Live APIの設定とVAD無効化

Live API のトークンは authTokens.create() で発行します。これもサーバー側でやります。

liveConnectConstraints: {
  model: "gemini-2.5-flash-native-audio-preview-12-2025",
  config: {
    sessionResumption: {},
    temperature: 0.7,
    responseModalities: [Modality.AUDIO],
    inputAudioTranscription: {},
    outputAudioTranscription: {},
    realtimeInputConfig: {
      // Live APIに音声を送らないのでVADは機能しない
      // disabled: true だとセッション維持に問題が出たので、
      // 60秒という長いsilenceDurationMsで実質無効化
      automaticActivityDetection: {
        silenceDurationMs: 60000,
      },
    },
    speechConfig: {
      languageCode: "ja-JP",
      voiceConfig: {
        prebuiltVoiceConfig: { voiceName },
      },
    },
  },
},

音声を Live API に送らない以上、内蔵 VAD は動きようがないんですが、試したときにdisabled: true にするとセッション自体が不安定になったので(?)、60秒を設定しています。多分GAの時のは直ってるじゃないでしょうか。

voiceConfigspeechConfigEphemeral TokenliveConnectConstraints に含めるのが確実です。クライアント側の live.connect() で設定しても反映されないケースがありました。

トークンは uses: n で発行(初回接続 + 再接続n回)。sessionResumption を有効にしているので、一時的な切断後も会話の文脈が保てます。


エコーキャンセルと会話の制御

AIとの対話は音声をマイクが拾って Deepgram が誤認識し、turnComplete が飛んでAIが途中で切れたります。 getUserMedia のエコーキャンセルは Web Audio API 経由の出力には効かないことがあるらしい(?)です。

github.com

Chromium

なので、出力音声のRMSを監視して AI が話している間はDeepgramの結果を捨てる方式にしました。

const checkAISpeaking = useCallback(() => {
  const analyser = getOutputAnalyserRef.current?.();
  if (!analyser) return false;
  let data = outputRmsDataRef.current;
  if (!data || data.length !== analyser.fftSize) {
    data = new Float32Array(new ArrayBuffer(analyser.fftSize * 4));
    outputRmsDataRef.current = data;
  }
  analyser.getFloatTimeDomainData(data);
  return calculateRms(data) >= 0.01; // OUTPUT_SPEAKING_THRESHOLD_RMS
}, []);

出力の AnalyserNode から波形を取って RMS を計算し、閾値以上なら「AIが話している」と判定します。

音声の入出力パイプラインとしては、入力側は AudioWorklet で Float32 → Int16 (PCM) に変換して Deepgram に送信、出力側は Live API から来る Base64 エンコードの PCM を Int16 → Float32 に変換して Web Audio API でスケジューリング再生しています。nextStartTime を追跡してチャンク間のギャップを防ぐことで、途切れのないようにしています。


Deepgram を追加している分 Gemini 単体構成よりコストは増えますが、日本語の認識精度とターン制御の自由度が増えたなーという感じです。

Better AuthのEmail OTP + DrizzleでメールOTP認証

Better Authを利用したサービスでGoogleログイン後に Email OTP(6桁のワンタイムパスワード)でメールアドレスの所有確認を挟みたいな〜という場面があり、 Better AuthemailOTP プラグインDrizzle Adapter を組み合わせて、OAuth ログイン後のメール認証を実装を楽できたのでメモとして残す。


昨今色々なサービスで使われているEmail OTP。OTP の生成・ハッシュ化・保存・検証・有効期限管理を自前で実装するのは結構面倒です(?)

developer.mozilla.org

Drizzle Adapter の schema mapping

Better Auth は内部で user, session, account, verification の4テーブルを使うが、Drizzle Adapter では 自分で定義したスキーマをそのまま渡す形になる。

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "mysql",
    schema: {
      user: usersTable,       // 自分の Drizzle テーブル定義
      session: sessionsTable,
      account: accountsTable,
      verification: verificationTable,
    },
  }),
});

これの何がいいかというと、Better Auth が期待するカラムさえ含んでいれば、独自カラムを自由に足せる。例えば user テーブルにアプリ固有の registrationStatus を追加して、メール認証前後でステータスを切り替えるような使い方ができる。

const usersTable = mysqlTable("user", {
  id: varchar("id", { length: 36 }).primaryKey(),
  name: varchar("name", { length: 255 }).notNull(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  emailVerified: boolean("email_verified").default(false).notNull(), // Better Auth が管理
  // ↓ アプリ固有のカラムを自由に追加できる
  registrationStatus: mysqlEnum("registration_status", ["pending", "registered"])
    .default("registered").notNull(),
  // ...timestamps
});

verification テーブルには identifier(メールアドレス)、value(OTP ハッシュ)、expiresAt(有効期限)が入る。OTP を平文保存しないのは Better Auth が内部でやってくれている。

emailOTP プラグインの設定

サーバー側の設定は↓だけ。

import { emailOTP } from "better-auth/plugins";

export const auth = betterAuth({
  // ...database, socialProviders etc.
  plugins: [
    emailOTP({
      async sendVerificationOTP({ email, otp, type }) {
        if (type === "email-verification") {
          // ここだけ自分で実装する(Resend, SendGrid, SES etc.)
          await sendEmail({ to: email, subject: "認証コード", body: otp });
        }
      },
      otpLength: 6,
      expiresIn: 10 * 60, // 10 minutes
    }),
  ],
});

sendVerificationOTP コールバックで Better Auth から OTP の平文を受け取り、好きなメール送信サービスに渡すだけ。type には "sign-in" / "email-verification" / "forget-password" が来るので、用途に応じてハンドルすればいい。

github.com

import { createAuthClient } from "better-auth/react";
import { emailOTPClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  plugins: [emailOTPClient()],
});

// OTP 送信
await authClient.emailOtp.sendVerificationOtp({ email, type: "email-verification" });
// OTP 検証
await authClient.emailOtp.verifyEmail({ email, otp: code });

databaseHooks

Better Auth には databaseHooks という仕組みがあり、user.create.after などでユーザー作成直後にフック処理を差し込める。OAuth ログイン時に関連テーブルを初期化したり、ステータスを設定したりするのに便利。

ただし databaseHooks の after フックとトランザクションの関係はバージョンによって挙動が異なる。v1.4 系では after フックがトランザクション内で実行される。

github.com

v1.5.0-beta 以降では after フックがトランザクションのコミット後に実行されるよう修正が入っている

github.com

afterフック内で副作用のある処理を行う場合は、使用バージョンのトランザクション挙動を確認しておくのが無難。

まとめ

Better Auth + Drizzle は、スキーママイグレーションを Drizzle 側でコントロールしつつ認証機能を載せられるのが良い。emailOTP プラグインは設定が最小限で、OTP のライフサイクル管理を全部任せられる。Hono + Drizzle でも問題なく動いた。

Vercel OIDCを利用してGoogle Cloudリソースへアクセスする

自分の昔の記憶(2022年頃)ではVercelから Google Cloudへの認証に Workload Identity Federation が使えず、Service Account の秘密鍵をVercelの環境変数に登録する必要があり微妙だなーと思っていました。 久しぶりに調べてみたところ Vercel OIDC がサポートされており、秘密鍵なしでフェデレーションできるようになっていました。

vercel.com

vercel.com

例えば、Next.js のサーバー上で Firestore へアクセスするみたいなユースケースが死ぬほどあります(個人的にあんまり好きではない)が、雑に調べるとみんな Service Account の 秘密鍵環境変数に登録して〜みたいなことやってます。鍵の漏洩リスクやローテーション管理を考えると、そんなことしないほうが良いです。

とりあえず使ってみる

Workload Identity Federation は、外部 IdP のトークンを STS で検証し一時的な認証情報と交換する仕組みです。Service Account impersonation で外部ワークロードがService Accountの権限を使いGoogle Cloud リソースにアクセスできます。

Vercel OIDC の場合、Vercel が発行したOIDC トークンSTS で検証 → SA を借用、という流れです。図にすると↓

このフローでは google-cloud-node-coreExternalAccountClient を使い、subject_token_suppliergetVercelOidcToken を動的に渡します。

vercel.com

Firestoreのアクセスで使ってみる


Provider と SA を用意します。

resource "google_iam_workload_identity_pool" "vercel" {
  workload_identity_pool_id = "vercel-pool"
  display_name              = "Vercel Pool"
}

resource "google_iam_workload_identity_pool_provider" "vercel" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.vercel.workload_identity_pool_id
  workload_identity_pool_provider_id = "vercel-provider"
  display_name                       = "Vercel Provider"

  oidc {
    issuer_uri = "https://oidc.vercel.com/${var.vercel_team_slug}"
  }

  attribute_mapping = {
    "google.subject" = "assertion.sub"
    "attribute.sub"  = "assertion.sub"
  }

  attribute_condition = "assertion.sub == 'owner:${var.vercel_team_slug}:project:${var.vercel_project_id}:environment:${each.value}'"
}

resource "google_service_account" "vercel_app" {
  account_id   = "app"
  display_name = "Service Account"
}

resource "google_project_iam_member" "firestore_user" {
  project = var.project_id
  role    = "roles/datastore.user"
  member  = "serviceAccount:${google_service_account.vercel_app.email}"
}

resource "google_service_account_iam_member" "vercel_impersonation" {
  for_each           = toset(var.vercel_environments)
  service_account_id = google_service_account.vercel_app.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principal://iam.googleapis.com/${google_iam_workload_identity_pool.vercel.name}/attribute.sub/owner:${var.vercel_team_slug}:project:${var.vercel_project_id}:environment:${each.value}"
}

resource "google_service_account_iam_member" "vercel_token_creator" {
  for_each           = toset(var.vercel_environments)
  service_account_id = google_service_account.vercel_app.name
  role               = "roles/iam.serviceAccountTokenCreator"
  member             = "principal://iam.googleapis.com/${google_iam_workload_identity_pool.vercel.name}/attribute.sub/owner:${var.vercel_team_slug}:project:${var.vercel_project_id}:environment:${each.value}"
}

attribute_condition でproject + environmentを絞りましょう。これがないと同じ team 内の別プロジェクトからもアクセスできてしまいます。

次にアプリケーション側。後述する理由でfirebase-adminは使わず、@google-cloud/firestoreauthClientを直接渡します。

import { Firestore } from "@google-cloud/firestore";
import { ExternalAccountClient } from "google-auth-library";
import { getVercelOidcToken } from "@vercel/oidc";

const authClient = ExternalAccountClient.fromJSON({
  type: "external_account",
  audience: `//iam.googleapis.com/projects/${GCP_PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/providers/${PROVIDER_ID}`,
  subject_token_type: "urn:ietf:params:oauth:token-type:jwt",
  token_url: "https://sts.googleapis.com/v1/token",
  service_account_impersonation_url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${SERVICE_ACCOUNT_EMAIL}:generateAccessToken`,
  subject_token_supplier: {
    getSubjectToken: getVercelOidcToken,
  },
});

const firestore = new Firestore({
  projectId: "my-project",
  databaseId: "my-database",
  authClient,
});

これだけでService Accountが発行したトークンを利用してFirestoreをクエリできます。

firebase-adminに関して

firebase-adminのFirestoreモジュール(firebase-admin/firestore)は、内部で@google-cloud/firestoreを初期化する際にcredentialをチェックしています。

firestore-internal.tsgetFirestoreOptions では:

  1. credential instanceof ServiceAccountCredential → OK
  2. isApplicationDefault(credential) → OK
  3. それ以外firestore/invalid-credential エラー

ExternalAccountClient はどちらにも該当しないため、以下のエラーになります。

Failed to initialize Google Cloud Firestore client with the available credentials. Must initialize the SDK with a certificate credential or application default credentials to use Cloud Firestore API.

firebase-admin v13 は external_account 型の credential ファイルをサポートしましたが、Vercel OIDC には使えません。

github.com

applicationDefault() の内部経路

  1. ApplicationDefaultCredentialnew GoogleAuth({ scopes }) を呼ぶだけで、authClientsubject_token_supplier を渡す手段がない
  2. GoogleAuthGOOGLE_APPLICATION_CREDENTIALS から JSON を読み込む
  3. type: "external_account" を検出し ExternalAccountClient.fromJSON を呼ぶ
  4. JSON から IdentityPoolClient を生成する

ここで問題になるのが subject_token_supplier です。これは関数を含むオブジェクトなので JSONシリアライズできません。applicationDefault()JSON ファイルからしか credential を読まないため、getVercelOidcToken を渡す方法がないわけです。

No valid Identity Pool "credential_source" provided, must be either file, url, or certificate.

なのでVercel OIDCを使う時は@google-cloud/firestoreを直接使う必要があります。@google-cloud/firestore なら authClient を直接渡せます。経路は面倒なので説明しませんが興味がある人はこの辺を↓を見てください。

github.com

github.com

github.com

github.com

まとめ

Firestoreの例で示しましたが、他も同様な感じでOIDCが使えると思います。

Cloudflare WorkersでDiscord APIのRatelimitに立ち向かう

※この記事は個人開発の遊びの内容です。

サービスの前提

VTuberの配信情報を通知するDiscordBot

  • 導入サーバー数: 約450サーバー(2026/1/28現在)
  • 通知頻度: 1分ごとに配信情報を全Discordサーバーへと配信する(昔ながらのCron方式)

関連資料:

speakerdeck.com

speakerdeck.com

zenn.dev

Discord APIの制限

Discord APIには50リクエスト/秒のGlobal Rate Limitと個別のエンドポイントへのRate Limitがあります。これを超えると429レスポンスが返され、Retry-Afterヘッダーで指定された時間だけ待機する必要があります。

discord.com

Cloudflare Workersの制限

Cloudflare Workersには同時接続6本という制限があります。

この制限は公式ドキュメントによると6接続を超えたリクエストは待機キューに入り、既存接続が閉じるまで開始されないという説明がされています。

公式ドキュメントの説明:

Once an invocation has six connections open, it can still attempt to open additional connections. These attempts are put in a pending queue — the connections will not be initiated until one of the currently open connections has closed.

ただ実際に運用している中でどうにも待機キューからDropしてしまうリクエストがあるような動きをすることがありました。 似たようなIssueもありこれがCloudflareの意図した仕様なのか、workerdの実装上の問題なのかはよく分かってません。(誰か知ってたら教えてください)

github.com github.com

いずれにせよ、Cloudflare WorkersからDiscord APIのような外部API呼び出しでは、このような不確実性を考慮した設計が必要です。

全体アーキテクチャ

Cron → Workflows → Service Bindings という流れで処理しています。

各層の役割:

技術 役割
スケジューリング Cron Triggers 1分ごとにWorkflowを起動
処理分割 Workflows 100チャンネル単位でステップ分割(SubRequest数に1024の制限がある)
RPC呼び出し Service Bindings 同一isolateでの実行(になる場合がある)
状態管理 グローバル変数 discordenoのRate Limit状態・接続数の保持
Rate Limit discordeno 429自動リトライ・エンドポイント別追跡

developers.cloudflare.com

developers.cloudflare.com

Cloudflare Queueを使った分散処理も検討しましたが、今回のユースケースでは以下の理由でWorkflows + Service Bindingsを採用しました。

  • 実際に運用していく中でキューイングした時にConsumer側の実行開始時間が安定しない
  • 運によりますが短時間であればDiscordClientを同一isolateで扱える( discordenoのRestManagerは、APIレスポンスのX-RateLimit-*ヘッダーを解析し、エンドポイントごとのRate Limit状態を内部で保持しているので相性がいい)

なので、今回は単一WorkerへService Bindingで接続するような感じにしてます。

グローバル変数によるソフトリミット

Cloudflare Workersでは、グローバル変数がisolate内で共有される場合があります。これを活用してDiscord ClientとConcurrency Limiterをグローバルに保持しています。

ちなみに公式ドキュメントでは、グローバル変数による状態管理は推奨されていません

Global variables are not reliable for storing state or passing data between requests because isolates can be evicted at any time.

今回はRateLimit50rpsギリギリを攻めつつ、インメモリでヘッダー制御等を効率よく行いたかったのでグローバル変数を利用しました。

/**
 * Global concurrency limiter for Cloudflare Workers.
 * Cloudflare Workers have a limit of 6 simultaneous outbound connections.
 * This limiter is shared across all requests within the same isolate.
 */
let globalConcurrencyLimiter: DiscordConcurrencyLimiter | null = null;

const getOrCreateConcurrencyLimiter = (): DiscordConcurrencyLimiter => {
  if (!globalConcurrencyLimiter) {
    globalConcurrencyLimiter = createDiscordConcurrencyLimiter();
    AppLogger.info("Discord concurrency limiter initialized");
  }
  return globalConcurrencyLimiter;
};

/**
 * Global Discord client instance shared across all requests within the same isolate.
 * This ensures that the discordeno RestManager's internal state (connection pool,
 * rateLimitedPaths map, etc.) is shared across requests for better efficiency.
 */
let globalDiscordClient: IDiscordClient | null = null;

export const getOrCreateDiscordClient = (env: DiscordEnv): IDiscordClient => {
  if (globalDiscordClient) {
    return globalDiscordClient;
  }
  globalDiscordClient = createDiscordClient(env);
  return globalDiscordClient;
};

discordenoが側で下記を保持しているのでアプリ層は接続数の制御のみをするようにしてます。

  • rateLimitedPaths: エンドポイントごとのRate Limit状態
  • queues: 同一エンドポイントへのリクエストを直列化
  • X-RateLimit-*ヘッダーの自動解析

正直あまり推奨できない方法なので、厳密な状態管理が必要な場合は、Durable Objectsの使用を検討したほうが良いと思います。

developers.cloudflare.com

色々問題はあるものの1分以内で処理は終わっているので、頑張っているんじゃないかなと思います。(そもそもCronでやらないほうが良いという話は置いておいて)

参考資料

discord.com

developers.cloudflare.com

developers.cloudflare.com

developers.cloudflare.com

developers.cloudflare.com

developers.cloudflare.com

developers.cloudflare.com

github.com

github.com

github.com